mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
fix(git): migrate repo operations to libgit2
This commit is contained in:
parent
e31c921cd0
commit
1502245c7c
10 changed files with 1629 additions and 734 deletions
84
Cargo.lock
generated
84
Cargo.lock
generated
|
|
@ -1460,6 +1460,21 @@ dependencies = [
|
|||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "github-copilot-sdk"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2156,6 +2171,20 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.18.4+1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
|
|
@ -2184,6 +2213,32 @@ dependencies = [
|
|||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
|
|
@ -2853,6 +2908,7 @@ name = "op-git"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"git2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
|
|
@ -3003,12 +3059,30 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -3698,7 +3772,7 @@ version = "0.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
|
|
@ -3732,7 +3806,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4801,6 +4875,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ version.workspace = true
|
|||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "System-git-backed version control for OpenPencil documents"
|
||||
description = "Git-backed version control for OpenPencil documents"
|
||||
|
||||
# Native-only: this crate drives the system `git` executable via
|
||||
# `std::process::Command`. It is the Rust counterpart of the TS
|
||||
# Electron app's `apps/desktop/git/git-sys.ts` system-git backend.
|
||||
# No heavy dependencies — the wire is plain subprocess + text parsing.
|
||||
# Native-only: in-process version control via libgit2 (`git2`),
|
||||
# vendored so no system `git` binary is required at runtime. Replaces
|
||||
# the former `std::process::Command` system-git backend (which failed
|
||||
# under macOS TCC when the subprocess touched a sandboxed directory and
|
||||
# broke entirely on machines without git installed).
|
||||
[dependencies]
|
||||
# In-process libgit2. `vendored-libgit2` builds + statically links
|
||||
# libgit2 from source (cmake + cc), so the shipped binary carries its
|
||||
# own git engine. Keeps the default `https` / `ssh` transports.
|
||||
git2 = { version = "=0.20.3", features = ["vendored-libgit2"] }
|
||||
thiserror = { workspace = true }
|
||||
# Credential store (`auth.rs`) persistence — the host-keyed token /
|
||||
# SSH-key table is serialized to a JSON file.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Branch listing, creation, deletion and switching.
|
||||
|
||||
use git2::{build::CheckoutBuilder, BranchType};
|
||||
|
||||
use crate::{GitError, GitRepo};
|
||||
|
||||
/// A local branch.
|
||||
|
|
@ -12,37 +14,59 @@ pub struct Branch {
|
|||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// The currently checked-out branch, or `None` on a detached
|
||||
/// `HEAD` (or a fresh repo with no commits yet).
|
||||
/// The currently checked-out branch, or `None` on a detached `HEAD`.
|
||||
/// A fresh repo with no commits is still ON a branch (the unborn
|
||||
/// `main`), so it reports that branch name — not `None`.
|
||||
pub fn current_branch(&self) -> Result<Option<String>, GitError> {
|
||||
let out = self.run(&["branch", "--show-current"])?;
|
||||
let name = out.trim();
|
||||
Ok(if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
})
|
||||
let repo = self.open()?;
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head,
|
||||
// Unborn branch (no commits yet): `HEAD` does not resolve to a
|
||||
// commit, but it IS a symbolic ref to the branch the first
|
||||
// commit will create (`main`). Read that target so the panel
|
||||
// shows `main`, not a (wrong) "detached HEAD" — matching what
|
||||
// the subprocess `git status --branch` reported here.
|
||||
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
|
||||
return Ok(unborn_head_branch(&repo));
|
||||
}
|
||||
Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// A detached `HEAD` points straight at a commit, not a branch.
|
||||
if !head.is_branch() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(head.shorthand().map(str::to_string))
|
||||
}
|
||||
|
||||
/// Every local branch, each flagged with whether it is current.
|
||||
pub fn branches(&self) -> Result<Vec<Branch>, GitError> {
|
||||
let current = self.current_branch()?;
|
||||
let out = self.run(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
|
||||
Ok(out
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|name| Branch {
|
||||
is_current: current.as_deref() == Some(name),
|
||||
name: name.to_string(),
|
||||
})
|
||||
.collect())
|
||||
let repo = self.open()?;
|
||||
let mut result = Vec::new();
|
||||
for entry in repo.branches(Some(BranchType::Local))? {
|
||||
let (branch, _kind) = entry?;
|
||||
// `name()` is `Ok(None)` for a non-UTF-8 ref name; skip it
|
||||
// rather than fabricate a lossy name.
|
||||
if let Some(name) = branch.name()? {
|
||||
result.push(Branch {
|
||||
is_current: current.as_deref() == Some(name),
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Create a branch `name` at the current `HEAD`, without
|
||||
/// switching to it.
|
||||
pub fn create_branch(&self, name: &str) -> Result<(), GitError> {
|
||||
self.run(&["branch", name])?;
|
||||
let repo = self.open()?;
|
||||
// Resolve `HEAD` to the commit the new branch should point at.
|
||||
let target = repo.head()?.peel_to_commit()?;
|
||||
// `force = false` — refuse to clobber an existing branch of the
|
||||
// same name, matching `git branch <name>`.
|
||||
repo.branch(name, &target, false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -50,19 +74,49 @@ impl GitRepo {
|
|||
/// delete a branch whose commits are not merged elsewhere, so
|
||||
/// work cannot be silently lost.
|
||||
pub fn delete_branch(&self, name: &str) -> Result<(), GitError> {
|
||||
self.run(&["branch", "-d", name])?;
|
||||
let repo = self.open()?;
|
||||
let mut branch = repo.find_branch(name, BranchType::Local)?;
|
||||
branch.delete()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch the working tree to branch `name`.
|
||||
pub fn switch_branch(&self, name: &str) -> Result<(), GitError> {
|
||||
self.run(&["switch", name])?;
|
||||
let repo = self.open()?;
|
||||
// Point `HEAD` at the branch ref, then check its tree out into
|
||||
// the working directory. `force` mirrors the subprocess path's
|
||||
// tree-overwriting behaviour so the working tree always matches
|
||||
// the branch after a switch.
|
||||
let refname = format!("refs/heads/{name}");
|
||||
repo.set_head(&refname)?;
|
||||
let mut checkout = CheckoutBuilder::new();
|
||||
checkout.force();
|
||||
repo.checkout_head(Some(&mut checkout))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create branch `name` and switch to it in one step.
|
||||
pub fn create_and_switch_branch(&self, name: &str) -> Result<(), GitError> {
|
||||
self.run(&["switch", "--create", name])?;
|
||||
let repo = self.open()?;
|
||||
// Create the branch at the current `HEAD` commit, then attach
|
||||
// `HEAD` to it. The new branch shares `HEAD`'s tree, so the
|
||||
// working tree needs no file changes — a safe checkout keeps any
|
||||
// uncommitted edits intact, matching `git switch --create`.
|
||||
let target = repo.head()?.peel_to_commit()?;
|
||||
repo.branch(name, &target, false)?;
|
||||
let refname = format!("refs/heads/{name}");
|
||||
repo.set_head(&refname)?;
|
||||
repo.checkout_head(Some(CheckoutBuilder::new().safe()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch name a fresh (unborn-`HEAD`) repository will create on its
|
||||
/// first commit — read from `HEAD`'s symbolic target (`refs/heads/main`
|
||||
/// → `main`). `None` when `HEAD` is not a symbolic ref to a branch.
|
||||
fn unborn_head_branch(repo: &git2::Repository) -> Option<String> {
|
||||
repo.find_reference("HEAD")
|
||||
.ok()
|
||||
.and_then(|r| r.symbolic_target().map(str::to_string))
|
||||
.and_then(|t| t.strip_prefix("refs/heads/").map(str::to_string))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
use std::path::Path;
|
||||
|
||||
use git2::{Diff, DiffFormat, DiffLineType, DiffOptions, Repository, Sort, Tree};
|
||||
|
||||
use crate::{GitError, GitRepo};
|
||||
|
||||
/// One commit in the repository history.
|
||||
|
|
@ -21,105 +23,188 @@ pub struct Commit {
|
|||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Field separator for the `git log` pretty format — the ASCII unit
|
||||
/// separator (`0x1f`), which never appears in commit metadata.
|
||||
const SEP: char = '\u{1f}';
|
||||
|
||||
impl GitRepo {
|
||||
/// The most recent `limit` commits on the current branch, newest
|
||||
/// first. A repository with no commits yet yields an empty list
|
||||
/// rather than an error.
|
||||
pub fn log(&self, limit: usize) -> Result<Vec<Commit>, GitError> {
|
||||
// `git log` errors on a commit-less repo; probe `HEAD` first.
|
||||
if self
|
||||
.run(&["rev-parse", "--verify", "--quiet", "HEAD"])
|
||||
.is_err()
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
let repo = self.open()?;
|
||||
// A fresh repo with no commits has an unborn `HEAD`; probe it
|
||||
// first — the old code did this with `rev-parse --verify HEAD`.
|
||||
// `revwalk.push_head` on such a repo fails with a generic
|
||||
// `Reference`-class error ("reference 'refs/heads/main' not
|
||||
// found") rather than the dedicated `UnbornBranch` code, so we
|
||||
// can't rely on classifying *its* error; `Repository::head`
|
||||
// reports the unborn state cleanly.
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head,
|
||||
Err(e)
|
||||
if e.code() == git2::ErrorCode::UnbornBranch
|
||||
|| e.code() == git2::ErrorCode::NotFound =>
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let head_oid = head.peel_to_commit()?.id();
|
||||
|
||||
let mut walk = repo.revwalk()?;
|
||||
// Newest first — `Sort::TIME` walks in reverse-chronological
|
||||
// order, the same ordering `git log` defaults to.
|
||||
walk.set_sorting(Sort::TIME)?;
|
||||
walk.push(head_oid)?;
|
||||
|
||||
let mut commits = Vec::new();
|
||||
for oid in walk.take(limit) {
|
||||
let oid = oid?;
|
||||
let commit = repo.find_commit(oid)?;
|
||||
commits.push(commit_to_record(&commit));
|
||||
}
|
||||
let limit_arg = limit.to_string();
|
||||
let format = format!("--pretty=format:%H{SEP}%h{SEP}%an{SEP}%ae{SEP}%at{SEP}%s");
|
||||
let raw = self.run(&["log", "-n", &limit_arg, &format])?;
|
||||
Ok(parse_log(&raw))
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// The unified diff of unstaged working-tree changes. With
|
||||
/// `path` set, the diff is restricted to that path.
|
||||
pub fn diff(&self, path: Option<&Path>) -> Result<String, GitError> {
|
||||
match path.and_then(Path::to_str) {
|
||||
Some(path) => self.run(&["diff", "--", path]),
|
||||
None => self.run(&["diff"]),
|
||||
let repo = self.open()?;
|
||||
let mut opts = DiffOptions::new();
|
||||
if let Some(spec) = path.and_then(Path::to_str) {
|
||||
opts.pathspec(spec);
|
||||
}
|
||||
// `git diff` (no `--cached`) is the index-vs-working-tree delta —
|
||||
// exactly the unstaged changes the panel renders.
|
||||
let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?;
|
||||
render_diff(&diff)
|
||||
}
|
||||
|
||||
/// The unified diff of staged (index vs `HEAD`) changes.
|
||||
pub fn diff_staged(&self, path: Option<&Path>) -> Result<String, GitError> {
|
||||
match path.and_then(Path::to_str) {
|
||||
Some(path) => self.run(&["diff", "--cached", "--", path]),
|
||||
None => self.run(&["diff", "--cached"]),
|
||||
let repo = self.open()?;
|
||||
let mut opts = DiffOptions::new();
|
||||
if let Some(spec) = path.and_then(Path::to_str) {
|
||||
opts.pathspec(spec);
|
||||
}
|
||||
// `git diff --cached` is the `HEAD`-tree-vs-index delta. Before
|
||||
// the first commit there is no `HEAD` tree — pass `None`, which
|
||||
// libgit2 treats as the empty tree, so a brand-new repo's staged
|
||||
// additions still diff cleanly.
|
||||
let head_tree = head_tree(&repo)?;
|
||||
let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
|
||||
render_diff(&diff)
|
||||
}
|
||||
|
||||
/// The full patch a single commit introduced — `git show` of
|
||||
/// `rev` (a hash, `short_hash`, or any rev-spec). The output
|
||||
/// carries the commit metadata header followed by the unified
|
||||
/// diff, so the Git panel can render a commit's changes the same
|
||||
/// way it renders a working-tree diff.
|
||||
/// The full patch a single commit introduced — the diff of `rev`
|
||||
/// (a hash, `short_hash`, or any rev-spec) against its first
|
||||
/// parent. The output is the unified diff so the Git panel can
|
||||
/// render a commit's changes the same way it renders a
|
||||
/// working-tree diff.
|
||||
pub fn commit_diff(&self, rev: &str) -> Result<String, GitError> {
|
||||
// `--stat` first gives a per-file summary, then the patch;
|
||||
// `git` writes uncoloured output to a pipe so no `--no-color`
|
||||
// is needed (matching `diff` above).
|
||||
self.run(&["show", "--stat", "-p", rev])
|
||||
let repo = self.open()?;
|
||||
// Resolve `rev` (hash / short hash / any rev-spec) to a commit.
|
||||
let object = repo.revparse_single(rev)?;
|
||||
let commit = object.peel_to_commit()?;
|
||||
let new_tree = commit.tree()?;
|
||||
// Diff against the first parent's tree; the root commit has no
|
||||
// parent, so its "before" side is the empty tree (`None`).
|
||||
let parent_tree = if commit.parent_count() > 0 {
|
||||
Some(commit.parent(0)?.tree()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), None)?;
|
||||
render_diff(&diff)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the separator-delimited `git log` output into commits.
|
||||
fn parse_log(raw: &str) -> Vec<Commit> {
|
||||
raw.lines()
|
||||
.filter_map(|line| {
|
||||
let fields: Vec<&str> = line.split(SEP).collect();
|
||||
if fields.len() < 6 {
|
||||
return None;
|
||||
/// Build a [`Commit`] record from a libgit2 commit. `summary` /
|
||||
/// `author` / `email` fall back to empty strings when libgit2 cannot
|
||||
/// decode them as UTF-8, matching the old separator-parse behaviour
|
||||
/// (a malformed field became an empty string rather than an error).
|
||||
fn commit_to_record(commit: &git2::Commit<'_>) -> Commit {
|
||||
let author = commit.author();
|
||||
// `Object::short_id` abbreviates the hash the way `git` does
|
||||
// (uniqueness-aware, honouring `core.abbrev`); fall back to a
|
||||
// 7-char prefix of the full hash if it is somehow unavailable.
|
||||
let hash = commit.id().to_string();
|
||||
let short_hash = commit
|
||||
.as_object()
|
||||
.short_id()
|
||||
.ok()
|
||||
.and_then(|buf| buf.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| hash.chars().take(7).collect());
|
||||
Commit {
|
||||
hash,
|
||||
short_hash,
|
||||
author: author.name().unwrap_or("").to_string(),
|
||||
email: author.email().unwrap_or("").to_string(),
|
||||
timestamp: commit.time().seconds(),
|
||||
summary: commit.summary().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree `HEAD` points at, or `None` before the first commit (an
|
||||
/// unborn `HEAD`). Any other failure to resolve `HEAD` propagates.
|
||||
fn head_tree(repo: &Repository) -> Result<Option<Tree<'_>>, GitError> {
|
||||
match repo.head() {
|
||||
Ok(head) => Ok(Some(head.peel_to_commit()?.tree()?)),
|
||||
// No commits yet — `HEAD` is unborn (or simply absent).
|
||||
Err(e)
|
||||
if e.code() == git2::ErrorCode::UnbornBranch
|
||||
|| e.code() == git2::ErrorCode::NotFound =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a libgit2 [`Diff`] to the same unified-diff string the
|
||||
/// `git diff` / `git show` subprocess produced — file headers, hunk
|
||||
/// headers, and `+`/`-`/` ` line prefixes — by replaying it through
|
||||
/// [`Diff::print`] with [`DiffFormat::Patch`] and concatenating every
|
||||
/// emitted line into a `String`.
|
||||
fn render_diff(diff: &Diff<'_>) -> Result<String, GitError> {
|
||||
let mut out = String::new();
|
||||
diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
// Content lines (context / addition / deletion) carry their
|
||||
// payload *without* the leading marker, so re-prepend the
|
||||
// origin char. Header lines (file header, hunk header, binary,
|
||||
// EOFNL markers) already contain their full text — emit them
|
||||
// verbatim.
|
||||
match line.origin_value() {
|
||||
DiffLineType::Context | DiffLineType::Addition | DiffLineType::Deletion => {
|
||||
out.push(line.origin())
|
||||
}
|
||||
Some(Commit {
|
||||
hash: fields[0].to_string(),
|
||||
short_hash: fields[1].to_string(),
|
||||
author: fields[2].to_string(),
|
||||
email: fields[3].to_string(),
|
||||
timestamp: fields[4].trim().parse().unwrap_or(0),
|
||||
summary: fields[5].to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
_ => {}
|
||||
}
|
||||
out.push_str(&String::from_utf8_lossy(line.content()));
|
||||
true
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `commit_to_record` is exercised against real commits in the
|
||||
/// integration suite (which builds fixture repos); here we only
|
||||
/// assert the empty-history contract is plumbed through a struct
|
||||
/// the rest of the crate can rely on.
|
||||
#[test]
|
||||
fn parses_separator_delimited_log() {
|
||||
let raw = format!(
|
||||
"abc123{SEP}abc{SEP}Ada{SEP}ada@x.dev{SEP}1700000000{SEP}first commit\n\
|
||||
def456{SEP}def{SEP}Bo{SEP}bo@x.dev{SEP}1700000100{SEP}second: a, b"
|
||||
);
|
||||
let commits = parse_log(&raw);
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].hash, "abc123");
|
||||
assert_eq!(commits[0].author, "Ada");
|
||||
assert_eq!(commits[0].timestamp, 1_700_000_000);
|
||||
// A summary containing commas survives — the separator is 0x1f.
|
||||
assert_eq!(commits[1].summary, "second: a, b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_malformed_lines() {
|
||||
let raw = format!("only{SEP}three{SEP}fields\ngarbage");
|
||||
assert!(parse_log(&raw).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_log_is_empty() {
|
||||
assert!(parse_log("").is_empty());
|
||||
fn commit_fields_are_addressable() {
|
||||
let c = Commit {
|
||||
hash: "abc123".to_string(),
|
||||
short_hash: "abc".to_string(),
|
||||
author: "Ada".to_string(),
|
||||
email: "ada@x.dev".to_string(),
|
||||
timestamp: 1_700_000_000,
|
||||
summary: "first commit".to_string(),
|
||||
};
|
||||
assert_eq!(c.hash, "abc123");
|
||||
assert_eq!(c.short_hash, "abc");
|
||||
assert_eq!(c.author, "Ada");
|
||||
assert_eq!(c.timestamp, 1_700_000_000);
|
||||
assert_eq!(c.summary, "first commit");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
//! System-`git`-backed version control for OpenPencil documents.
|
||||
//! 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.
|
||||
//! 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
|
||||
//!
|
||||
//! 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.
|
||||
//! 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 missing
|
||||
//! `git`, a non-repo path, or a non-zero `git` exit (with its
|
||||
//! stderr) — and never panics.
|
||||
//! 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};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
mod auth;
|
||||
mod branch;
|
||||
|
|
@ -109,6 +109,19 @@ impl GitError {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -132,48 +145,6 @@ pub struct Author {
|
|||
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).
|
||||
///
|
||||
|
|
@ -219,32 +190,29 @@ impl GitRepo {
|
|||
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);
|
||||
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()),
|
||||
}
|
||||
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`.
|
||||
/// 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 output = git_output(dir, &["init", "--initial-branch=main"])?;
|
||||
if !output.status.success() {
|
||||
return Err(GitError::Command {
|
||||
operation: "init".to_string(),
|
||||
stderr: stderr_of(&output),
|
||||
});
|
||||
}
|
||||
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()))
|
||||
}
|
||||
|
||||
|
|
@ -253,6 +221,15 @@ impl GitRepo {
|
|||
&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.
|
||||
|
|
@ -274,24 +251,38 @@ impl GitRepo {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read a single git config value, or `None` when it is unset.
|
||||
/// 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 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)
|
||||
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())
|
||||
}
|
||||
|
||||
/// Run `git <args>` in this repo, returning trimmed stdout on a
|
||||
/// zero exit. Shared by every operation in the sibling modules.
|
||||
/// 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 = git_output_env(&self.workdir, args, &self.auth_env)?;
|
||||
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: stderr_of(&output),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
//! Branch merging, the shared integration classifier, and
|
||||
//! merge-conflict handling.
|
||||
//!
|
||||
//! Backed by in-process `libgit2` (`git2`) rather than the system
|
||||
//! `git` executable: the merge analysis, fast-forward, merge-commit
|
||||
//! creation, conflict inspection and abort all run against the
|
||||
//! `git2::Repository` opened fresh for each call. The worktree-merge
|
||||
//! orchestration still goes through [`MergeWorktree`] so a
|
||||
//! conflicting merge is computed in a throwaway worktree and never
|
||||
//! marks up the live `.op` document.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use git2::{build::CheckoutBuilder, Oid, Repository, RepositoryState, ResetType};
|
||||
|
||||
use crate::worktree::MergeWorktree;
|
||||
use crate::{GitError, GitRepo};
|
||||
|
||||
|
|
@ -27,8 +37,13 @@ impl GitRepo {
|
|||
/// Merge `refname` (a branch, tag, or commit) into the current
|
||||
/// branch, classifying the outcome.
|
||||
pub fn merge(&self, refname: &str) -> Result<MergeOutcome, GitError> {
|
||||
let before = self.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
let target = self.run(&["rev-parse", refname])?.trim().to_string();
|
||||
let repo = self.open()?;
|
||||
let before = repo.head()?.peel_to_commit()?.id().to_string();
|
||||
let target = repo
|
||||
.revparse_single(refname)?
|
||||
.peel_to_commit()?
|
||||
.id()
|
||||
.to_string();
|
||||
self.integrate(&before, &target)
|
||||
}
|
||||
|
||||
|
|
@ -67,67 +82,127 @@ impl GitRepo {
|
|||
}
|
||||
// `before` is an ancestor of `target` — fast-forward.
|
||||
if self.is_ancestor(before, target) {
|
||||
self.run(&["merge", "--ff-only", target])?;
|
||||
let repo = self.open()?;
|
||||
let target_oid = Oid::from_str(target)?;
|
||||
fast_forward(&repo, target_oid)?;
|
||||
return Ok(MergeOutcome::FastForward);
|
||||
}
|
||||
// Diverged histories — a merge commit is required.
|
||||
match self.run(&["merge", "--no-edit", target]) {
|
||||
Ok(_) => Ok(MergeOutcome::Merge),
|
||||
Err(err @ GitError::Command { .. }) => {
|
||||
// A merge that halts on conflicts leaves conflict
|
||||
// markers in the tree rather than failing cleanly.
|
||||
if self.status().map(|s| s.has_conflicts()).unwrap_or(false) {
|
||||
Ok(MergeOutcome::Conflict)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
let repo = self.open()?;
|
||||
let before_oid = Oid::from_str(before)?;
|
||||
let target_oid = Oid::from_str(target)?;
|
||||
merge_commit_or_conflict(&repo, before_oid, target_oid)
|
||||
}
|
||||
|
||||
/// Whether commit `ancestor` is an ancestor of commit
|
||||
/// `descendant` (a commit is its own ancestor).
|
||||
fn is_ancestor(&self, ancestor: &str, descendant: &str) -> bool {
|
||||
// `merge-base --is-ancestor` exits 0 when true, 1 when false;
|
||||
// a non-zero exit surfaces as `Err` from `run`.
|
||||
self.run(&["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.is_ok()
|
||||
// `git2::Repository::graph_descendant_of(descendant, ancestor)`
|
||||
// is the libgit2 equivalent of `merge-base --is-ancestor`, but
|
||||
// it returns `false` when the two commits are equal — so the
|
||||
// "a commit is its own ancestor" case is handled explicitly to
|
||||
// preserve the subprocess behaviour.
|
||||
if ancestor == descendant {
|
||||
return true;
|
||||
}
|
||||
let Ok(repo) = self.open() else {
|
||||
return false;
|
||||
};
|
||||
let (Ok(anc), Ok(desc)) = (Oid::from_str(ancestor), Oid::from_str(descendant)) else {
|
||||
return false;
|
||||
};
|
||||
repo.graph_descendant_of(desc, anc).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a merge is currently in progress (`MERGE_HEAD` exists).
|
||||
pub fn is_merging(&self) -> bool {
|
||||
self.run(&["rev-parse", "--verify", "--quiet", "MERGE_HEAD"])
|
||||
.is_ok()
|
||||
let Ok(repo) = self.open() else {
|
||||
return false;
|
||||
};
|
||||
// `RepositoryState::Merge` is set whenever `MERGE_HEAD` is
|
||||
// present; fall back to a direct ref probe in case the state
|
||||
// read is ambiguous.
|
||||
repo.state() == RepositoryState::Merge || repo.find_reference("MERGE_HEAD").is_ok()
|
||||
}
|
||||
|
||||
/// Abort an in-progress merge, restoring the pre-merge state.
|
||||
pub fn abort_merge(&self) -> Result<(), GitError> {
|
||||
self.run(&["merge", "--abort"])?;
|
||||
let repo = self.open()?;
|
||||
// `git merge --abort` is `git reset --hard` followed by a
|
||||
// clear of the merge metadata. A hard reset to `HEAD` throws
|
||||
// away the conflicted working-tree + index content, then
|
||||
// `cleanup_state` removes `MERGE_HEAD` / `MERGE_MSG`.
|
||||
let head = repo.head()?.peel_to_commit()?;
|
||||
let mut checkout = CheckoutBuilder::new();
|
||||
checkout.force();
|
||||
repo.reset(head.as_object(), ResetType::Hard, Some(&mut checkout))?;
|
||||
repo.cleanup_state()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repo-relative paths with unresolved merge conflicts.
|
||||
pub fn conflicted_files(&self) -> Result<Vec<String>, GitError> {
|
||||
let raw = self.run(&["diff", "--name-only", "--diff-filter=U"])?;
|
||||
Ok(raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect())
|
||||
let repo = self.open()?;
|
||||
let index = repo.index()?;
|
||||
let mut paths = Vec::new();
|
||||
for conflict in index.conflicts()? {
|
||||
let conflict = conflict?;
|
||||
// The path is identical across the three stages; take it
|
||||
// from whichever stage exists (a delete/modify conflict is
|
||||
// missing one of `our` / `their`).
|
||||
if let Some(path) = conflict_path(&conflict) {
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Mark `path` resolved by staging its current (resolved)
|
||||
/// content — `git add` of a once-conflicted file.
|
||||
/// content into the index — the libgit2 equivalent of `git add`
|
||||
/// of a once-conflicted file, which also clears the path's
|
||||
/// conflict entry (moving it to the index's resolve-undo section).
|
||||
pub fn mark_resolved(&self, path: &Path) -> Result<(), GitError> {
|
||||
self.stage(&[path])
|
||||
let repo = self.open()?;
|
||||
let mut index = repo.index()?;
|
||||
// `Index::add_path` needs a path relative to the work tree.
|
||||
let rel = self.repo_relative(path);
|
||||
index.add_path(&rel)?;
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finalize an in-progress merge once every conflict is resolved
|
||||
/// and staged, keeping git's generated merge message.
|
||||
pub fn complete_merge(&self) -> Result<(), GitError> {
|
||||
self.run(&["commit", "--no-edit"])?;
|
||||
let repo = self.open()?;
|
||||
// The two parents: `HEAD` (ours) and `MERGE_HEAD` (theirs).
|
||||
let ours = repo.head()?.peel_to_commit()?;
|
||||
let merge_head_oid =
|
||||
repo.find_reference("MERGE_HEAD")?
|
||||
.target()
|
||||
.ok_or_else(|| GitError::Command {
|
||||
operation: "commit".to_string(),
|
||||
stderr: "MERGE_HEAD does not resolve to a commit".to_string(),
|
||||
})?;
|
||||
let theirs = repo.find_commit(merge_head_oid)?;
|
||||
|
||||
// Write the (resolved) index out to a tree; a lingering
|
||||
// conflict makes `write_tree` fail, which is the correct
|
||||
// refusal to commit an unresolved merge.
|
||||
let mut index = repo.index()?;
|
||||
let tree_oid = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_oid)?;
|
||||
|
||||
// Keep git's generated merge message (`MERGE_MSG`) when present,
|
||||
// matching `git commit --no-edit`; otherwise synthesize one.
|
||||
let message =
|
||||
read_merge_msg(&repo).unwrap_or_else(|| format!("Merge commit '{}'", theirs.id()));
|
||||
|
||||
let sig = repo.signature()?;
|
||||
repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&ours, &theirs])?;
|
||||
repo.cleanup_state()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +212,16 @@ impl GitRepo {
|
|||
/// conflict has no base stage). Valid only while a merge is in
|
||||
/// progress with `path` unresolved.
|
||||
pub fn conflict_stages(&self, path: &str) -> ConflictStages {
|
||||
let stage = |n: u8| self.run(&["show", &format!(":{n}:{path}")]).ok();
|
||||
// Any failure to open the repo / read the index yields the
|
||||
// empty (all-`None`) stage set, matching the subprocess
|
||||
// version's "`git show` failed → `None`" behaviour.
|
||||
let Ok(repo) = self.open() else {
|
||||
return ConflictStages::default();
|
||||
};
|
||||
let Ok(index) = repo.index() else {
|
||||
return ConflictStages::default();
|
||||
};
|
||||
let stage = |n: i32| stage_blob(&repo, &index, path, n);
|
||||
ConflictStages {
|
||||
base: stage(1),
|
||||
ours: stage(2),
|
||||
|
|
@ -177,8 +261,14 @@ impl GitRepo {
|
|||
if self.is_merging() {
|
||||
return Err(GitError::MergeInProgress);
|
||||
}
|
||||
let head = self.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
let target = self.run(&["rev-parse", other])?.trim().to_string();
|
||||
let repo = self.open()?;
|
||||
let head = repo.head()?.peel_to_commit()?.id().to_string();
|
||||
let target = repo
|
||||
.revparse_single(other)?
|
||||
.peel_to_commit()?
|
||||
.id()
|
||||
.to_string();
|
||||
drop(repo);
|
||||
|
||||
// Ancestry short-circuits — no worktree needed, no mutation.
|
||||
if head == target || self.is_ancestor(&target, &head) {
|
||||
|
|
@ -191,36 +281,41 @@ impl GitRepo {
|
|||
return Err(GitError::WorkingTreeDirty);
|
||||
}
|
||||
|
||||
let head_oid = Oid::from_str(&head)?;
|
||||
let target_oid = Oid::from_str(&target)?;
|
||||
|
||||
// Compute the merge in a detached worktree pinned at HEAD.
|
||||
let worktree = MergeWorktree::create(self, merge_worktree_dir(), &head)?;
|
||||
let wrepo = worktree.repo();
|
||||
let wgit = worktree.repo();
|
||||
|
||||
// Fast-forward: exact, never conflicts.
|
||||
// Fast-forward: exact, never conflicts. The worktree would
|
||||
// simply land on `target`, so the live branch can advance
|
||||
// straight onto `target`.
|
||||
if self.is_ancestor(&head, &target) {
|
||||
wrepo.run(&["merge", "--ff-only", &target])?;
|
||||
let merged = wrepo.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
self.run(&["merge", "--ff-only", &merged])?;
|
||||
let live = self.open()?;
|
||||
fast_forward(&live, target_oid)?;
|
||||
return Ok(WorktreeMergeReport::clean(
|
||||
MergeOutcome::FastForward,
|
||||
merged,
|
||||
target,
|
||||
));
|
||||
}
|
||||
|
||||
// Diverged histories — a real merge commit, or conflicts.
|
||||
match wrepo.run(&["merge", "--no-edit", &target]) {
|
||||
Ok(_) => {
|
||||
let merged = wrepo.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
// The worktree built a merge commit on top of `head`;
|
||||
// the live branch can fast-forward onto it exactly.
|
||||
self.run(&["merge", "--ff-only", &merged])?;
|
||||
Ok(WorktreeMergeReport::clean(MergeOutcome::Merge, merged))
|
||||
}
|
||||
Err(err @ GitError::Command { .. }) => {
|
||||
let bag = collect_conflicts(wrepo)?;
|
||||
// Diverged histories — a real merge commit, or conflicts. Run
|
||||
// the merge inside the worktree so any markers stay quarantined.
|
||||
let wrepo = wgit.open()?;
|
||||
let merged = match merge_in_worktree(&wrepo, head_oid, target_oid)? {
|
||||
WorktreeMergeResult::Clean(merged) => merged,
|
||||
WorktreeMergeResult::Conflicts => {
|
||||
let bag = collect_conflicts(wgit)?;
|
||||
if bag.is_empty() {
|
||||
// A non-conflict failure (e.g. an unrelated
|
||||
// history) — surface it rather than swallow it.
|
||||
return Err(err);
|
||||
// `merge_in_worktree` reported conflicts but the
|
||||
// index shows none — treat as a non-conflict
|
||||
// failure and surface it rather than swallow it.
|
||||
let _ = abort_in_worktree(&wrepo);
|
||||
return Err(GitError::Command {
|
||||
operation: "merge".to_string(),
|
||||
stderr: "merge halted without a recorded conflict".to_string(),
|
||||
});
|
||||
}
|
||||
// Offer each conflicted file to the structured
|
||||
// resolver; write back + stage whatever it resolves.
|
||||
|
|
@ -235,31 +330,245 @@ impl GitRepo {
|
|||
stages.theirs.as_deref().unwrap_or(""),
|
||||
);
|
||||
if let Some(content) = resolved {
|
||||
let abs = wrepo.workdir().join(&file.path);
|
||||
let abs = wgit.workdir().join(&file.path);
|
||||
std::fs::write(&abs, content).map_err(|e| GitError::Io(e.to_string()))?;
|
||||
wrepo.stage(&[abs.as_path()])?;
|
||||
// Stage the resolved content into the worktree
|
||||
// index, which also clears the conflict entry.
|
||||
let mut index = wrepo.index()?;
|
||||
index.add_path(Path::new(&file.path))?;
|
||||
index.write()?;
|
||||
}
|
||||
}
|
||||
// Anything still unmerged after that?
|
||||
let residue = collect_conflicts(wrepo)?;
|
||||
if residue.is_empty() {
|
||||
// Every conflict was structurally auto-resolved —
|
||||
// complete the merge and fast-forward it back.
|
||||
wrepo.complete_merge()?;
|
||||
let merged = wrepo.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
self.run(&["merge", "--ff-only", &merged])?;
|
||||
return Ok(WorktreeMergeReport::clean(MergeOutcome::Merge, merged));
|
||||
let residue = collect_conflicts(wgit)?;
|
||||
if !residue.is_empty() {
|
||||
// Abort the worktree's half-merge; the worktree drop
|
||||
// then removes the directory entirely. The live tree
|
||||
// was never touched.
|
||||
let _ = abort_in_worktree(&wrepo);
|
||||
return Ok(WorktreeMergeReport::conflicted(residue));
|
||||
}
|
||||
// Abort the worktree's half-merge; the worktree drop
|
||||
// then removes the directory entirely. The live tree
|
||||
// was never touched.
|
||||
let _ = wrepo.abort_merge();
|
||||
Ok(WorktreeMergeReport::conflicted(residue))
|
||||
// Every conflict was structurally auto-resolved —
|
||||
// complete the merge in the worktree.
|
||||
complete_in_worktree(&wrepo, head_oid, target_oid)?
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
|
||||
// The worktree built (or fast-forwarded to) a commit on top of
|
||||
// `head`; the live branch can fast-forward onto it exactly.
|
||||
let live = self.open()?;
|
||||
let merged_oid = Oid::from_str(&merged)?;
|
||||
fast_forward(&live, merged_oid)?;
|
||||
Ok(WorktreeMergeReport::clean(MergeOutcome::Merge, merged))
|
||||
// `worktree` drops here → the throwaway worktree is removed.
|
||||
}
|
||||
|
||||
/// `path` made relative to the repository's work tree. An absolute
|
||||
/// path under the work tree is stripped to its relative remainder;
|
||||
/// an already-relative path is returned unchanged.
|
||||
fn repo_relative(&self, path: &Path) -> PathBuf {
|
||||
path.strip_prefix(self.workdir())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast-forward `repo`'s current `HEAD` to commit `oid`: check the
|
||||
/// target tree out into the working directory + index, then move the
|
||||
/// ref. An attached branch keeps its name (the branch ref advances);
|
||||
/// a detached `HEAD` (as in a throwaway worktree) is re-pointed at the
|
||||
/// commit directly.
|
||||
///
|
||||
/// The checkout is **SAFE**, not forced. libgit2 updates the work tree
|
||||
/// to the target but REFUSES — returning `GIT_ECONFLICT` *without
|
||||
/// applying any change* — when an update would overwrite local work: a
|
||||
/// modified tracked file, or an untracked file / directory in the way
|
||||
/// (every collision shape, honoring `core.ignorecase`). That refusal is
|
||||
/// mapped to [`GitError::WorkingTreeDirty`] so a fast-forward can never
|
||||
/// silently discard the user's edits — exactly the guard the subprocess
|
||||
/// `git pull` gave with "local changes / untracked working tree files
|
||||
/// would be overwritten by merge".
|
||||
fn fast_forward(repo: &Repository, oid: Oid) -> Result<(), GitError> {
|
||||
let commit = repo.find_commit(oid)?;
|
||||
let mut checkout = CheckoutBuilder::new(); // SAFE by default
|
||||
match repo.checkout_tree(commit.as_object(), Some(&mut checkout)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code() == git2::ErrorCode::Conflict => {
|
||||
return Err(GitError::WorkingTreeDirty);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
match repo.head() {
|
||||
Ok(mut head_ref) if head_ref.is_branch() => {
|
||||
head_ref.set_target(oid, "fast-forward")?;
|
||||
}
|
||||
// Detached HEAD (worktree) or no current branch — point HEAD
|
||||
// straight at the commit.
|
||||
_ => {
|
||||
repo.set_head_detached(oid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a real merge of `target` into `before` against `repo`'s live
|
||||
/// `HEAD`, producing a merge commit on a clean merge or leaving the
|
||||
/// repository in its conflicted merging state (returning
|
||||
/// [`MergeOutcome::Conflict`]) otherwise.
|
||||
fn merge_commit_or_conflict(
|
||||
repo: &Repository,
|
||||
before: Oid,
|
||||
target: Oid,
|
||||
) -> Result<MergeOutcome, GitError> {
|
||||
let annotated = repo.find_annotated_commit(target)?;
|
||||
let mut checkout = CheckoutBuilder::new();
|
||||
checkout.safe();
|
||||
repo.merge(&[&annotated], None, Some(&mut checkout))?;
|
||||
|
||||
let mut index = repo.index()?;
|
||||
if index.has_conflicts() {
|
||||
// Conflicts left in the working tree + index. Leave the merge
|
||||
// in progress (matching `git merge` halting on conflicts) so
|
||||
// the caller can inspect / abort / resolve it.
|
||||
return Ok(MergeOutcome::Conflict);
|
||||
}
|
||||
|
||||
// Clean merge — write the merged index out as the commit's tree
|
||||
// and create the two-parent merge commit, then clear the merge
|
||||
// metadata so the tree is no longer mid-merge.
|
||||
let tree_oid = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_oid)?;
|
||||
let ours = repo.find_commit(before)?;
|
||||
let theirs = repo.find_commit(target)?;
|
||||
let sig = repo.signature()?;
|
||||
repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
&sig,
|
||||
&format!("Merge commit '{target}'"),
|
||||
&tree,
|
||||
&[&ours, &theirs],
|
||||
)?;
|
||||
repo.cleanup_state()?;
|
||||
Ok(MergeOutcome::Merge)
|
||||
}
|
||||
|
||||
/// The result of computing a merge inside a throwaway worktree.
|
||||
enum WorktreeMergeResult {
|
||||
/// A clean merge — the merge commit's hash.
|
||||
Clean(String),
|
||||
/// The merge halted on conflicts left in the worktree index.
|
||||
Conflicts,
|
||||
}
|
||||
|
||||
/// Run a merge of `target` into the worktree's detached `HEAD`
|
||||
/// (pinned at `head`). On a clean merge it commits the result and
|
||||
/// returns its hash; on conflicts it leaves the worktree mid-merge
|
||||
/// and returns [`WorktreeMergeResult::Conflicts`].
|
||||
fn merge_in_worktree(
|
||||
wrepo: &Repository,
|
||||
head: Oid,
|
||||
target: Oid,
|
||||
) -> Result<WorktreeMergeResult, GitError> {
|
||||
let annotated = wrepo.find_annotated_commit(target)?;
|
||||
let mut checkout = CheckoutBuilder::new();
|
||||
checkout.safe();
|
||||
wrepo.merge(&[&annotated], None, Some(&mut checkout))?;
|
||||
|
||||
if wrepo.index()?.has_conflicts() {
|
||||
return Ok(WorktreeMergeResult::Conflicts);
|
||||
}
|
||||
let merged = commit_worktree_merge(wrepo, head, target)?;
|
||||
Ok(WorktreeMergeResult::Clean(merged))
|
||||
}
|
||||
|
||||
/// Commit a fully-resolved worktree merge (every conflict already
|
||||
/// staged) — the path taken once the structured resolver has cleared
|
||||
/// the last conflict. Returns the merge commit's hash.
|
||||
fn complete_in_worktree(wrepo: &Repository, head: Oid, target: Oid) -> Result<String, GitError> {
|
||||
commit_worktree_merge(wrepo, head, target)
|
||||
}
|
||||
|
||||
/// Write the worktree's (resolved) index out as a tree and create the
|
||||
/// two-parent merge commit on the detached `HEAD`, then clear the
|
||||
/// merge metadata. Returns the new commit's hash.
|
||||
fn commit_worktree_merge(wrepo: &Repository, head: Oid, target: Oid) -> Result<String, GitError> {
|
||||
let mut index = wrepo.index()?;
|
||||
let tree_oid = index.write_tree()?;
|
||||
let tree = wrepo.find_tree(tree_oid)?;
|
||||
let ours = wrepo.find_commit(head)?;
|
||||
let theirs = wrepo.find_commit(target)?;
|
||||
let sig = wrepo.signature()?;
|
||||
let merged = wrepo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
&sig,
|
||||
&format!("Merge commit '{target}'"),
|
||||
&tree,
|
||||
&[&ours, &theirs],
|
||||
)?;
|
||||
wrepo.cleanup_state()?;
|
||||
Ok(merged.to_string())
|
||||
}
|
||||
|
||||
/// Abort a worktree's half-finished merge — hard-reset to its `HEAD`
|
||||
/// and clear the merge metadata so the worktree is no longer mid-merge.
|
||||
fn abort_in_worktree(wrepo: &Repository) -> Result<(), GitError> {
|
||||
let head = wrepo.head()?.peel_to_commit()?;
|
||||
let mut checkout = CheckoutBuilder::new();
|
||||
checkout.force();
|
||||
wrepo.reset(head.as_object(), ResetType::Hard, Some(&mut checkout))?;
|
||||
wrepo.cleanup_state()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read git's generated `MERGE_MSG`, the message `git commit --no-edit`
|
||||
/// keeps for a merge commit. `None` when it is absent or unreadable.
|
||||
fn read_merge_msg(repo: &Repository) -> Option<String> {
|
||||
let path = repo.path().join("MERGE_MSG");
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let trimmed = text.trim_end();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
/// The blob content of merge stage `n` (1 = base, 2 = ours, 3 =
|
||||
/// theirs) for `path` in `index`, decoded as UTF-8 (lossy). `None`
|
||||
/// when that stage does not exist for the path.
|
||||
fn stage_blob(repo: &Repository, index: &git2::Index, path: &str, n: i32) -> Option<String> {
|
||||
let entry = index.get_path(Path::new(path), n)?;
|
||||
let blob = repo.find_blob(entry.id).ok()?;
|
||||
Some(String::from_utf8_lossy(blob.content()).into_owned())
|
||||
}
|
||||
|
||||
/// The repo-relative path of a conflict — taken from whichever of its
|
||||
/// three stage entries exists (a delete/modify conflict is missing one).
|
||||
fn conflict_path(conflict: &git2::IndexConflict) -> Option<String> {
|
||||
let entry = conflict
|
||||
.our
|
||||
.as_ref()
|
||||
.or(conflict.their.as_ref())
|
||||
.or(conflict.ancestor.as_ref())?;
|
||||
Some(String::from_utf8_lossy(&entry.path).into_owned())
|
||||
}
|
||||
|
||||
/// Classify a conflict from which of its three stage entries are
|
||||
/// present — the libgit2 counterpart of the porcelain `XY` codes the
|
||||
/// subprocess version read (`UU` / `AA` / `DU`-`UD`-`DD` / other).
|
||||
fn conflict_kind(conflict: &git2::IndexConflict) -> ConflictKind {
|
||||
let has_ancestor = conflict.ancestor.is_some();
|
||||
let has_our = conflict.our.is_some();
|
||||
let has_their = conflict.their.is_some();
|
||||
match (has_ancestor, has_our, has_their) {
|
||||
// Both sides changed a file that existed at the base — `UU`.
|
||||
(true, true, true) => ConflictKind::BothModified,
|
||||
// Both sides added the path with no common base — `AA`.
|
||||
(false, true, true) => ConflictKind::BothAdded,
|
||||
// One side deleted while the other kept/changed it — `DU` /
|
||||
// `UD` / `DD`.
|
||||
(_, false, true) | (_, true, false) => ConflictKind::DeleteModify,
|
||||
// Anything else (including a stageless record) — `Other`.
|
||||
_ => ConflictKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a single path conflicts in a merge.
|
||||
|
|
@ -373,42 +682,22 @@ fn merge_worktree_dir() -> PathBuf {
|
|||
std::env::temp_dir().join(format!("op-git-merge-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
/// Build a [`ConflictBag`] from a conflicted worktree's porcelain
|
||||
/// status. Only unmerged (`U`-coded) entries are collected.
|
||||
/// Build a [`ConflictBag`] from a conflicted worktree's index. Only
|
||||
/// the index's unmerged (conflict) entries are collected, in the order
|
||||
/// the conflict iterator yields them.
|
||||
///
|
||||
/// `--porcelain=v1 -z` is used deliberately: it emits NUL-terminated
|
||||
/// records with byte-exact, *unquoted* paths, so a path containing
|
||||
/// spaces — or leading / trailing whitespace — survives intact (the
|
||||
/// space-delimited, sometimes-quoted default format would not).
|
||||
/// `.op` documents carry their three merge-stage blobs so the caller
|
||||
/// can run a structured node-level merge; other files do not.
|
||||
fn collect_conflicts(repo: &GitRepo) -> Result<ConflictBag, GitError> {
|
||||
let raw = repo.run(&["status", "--porcelain=v1", "-z"])?;
|
||||
let git = repo.open()?;
|
||||
let index = git.index()?;
|
||||
let mut files = Vec::new();
|
||||
let mut records = raw.split('\0');
|
||||
while let Some(record) = records.next() {
|
||||
// `XY <path>` — two status codes, a space, then the path.
|
||||
// The trailing split element after the final NUL is empty.
|
||||
if record.len() < 4 {
|
||||
for conflict in index.conflicts()? {
|
||||
let conflict = conflict?;
|
||||
let Some(path) = conflict_path(&conflict) else {
|
||||
continue;
|
||||
}
|
||||
let xy = record.as_bytes();
|
||||
// Rename / copy entries carry a second NUL-delimited field
|
||||
// (the original path). Consume it so it is not misread as a
|
||||
// standalone status record on the next iteration.
|
||||
if xy[0] == b'R' || xy[0] == b'C' || xy[1] == b'R' || xy[1] == b'C' {
|
||||
let _ = records.next();
|
||||
}
|
||||
// Unmerged porcelain codes — see `git status` docs. Byte 3
|
||||
// onward is the path; bytes 0..3 are pure ASCII, so the
|
||||
// slice always falls on a char boundary.
|
||||
let kind = match &record[..2] {
|
||||
"UU" => ConflictKind::BothModified,
|
||||
"AA" => ConflictKind::BothAdded,
|
||||
"DD" | "DU" | "UD" => ConflictKind::DeleteModify,
|
||||
"AU" | "UA" => ConflictKind::Other,
|
||||
// Not an unmerged entry — skip it.
|
||||
_ => continue,
|
||||
};
|
||||
let path = record[3..].to_string();
|
||||
let kind = conflict_kind(&conflict);
|
||||
// `.op` documents carry their three merge-stage blobs so the
|
||||
// caller can run a structured node-level merge.
|
||||
let stages = path.ends_with(".op").then(|| repo.conflict_stages(&path));
|
||||
|
|
|
|||
|
|
@ -1,14 +1,43 @@
|
|||
//! Remote operations — clone, fetch, pull, push, remote config.
|
||||
//!
|
||||
//! The network operations (`clone` / `fetch` / `pull` / `push`)
|
||||
//! depend on the ambient git credential / SSH setup; dedicated
|
||||
//! credential + SSH-key handling lands in a later increment. They
|
||||
//! are not unit-tested here (no network in tests); the remote-config
|
||||
//! readers / writers are.
|
||||
//! The network operations (`clone` / `fetch` / `pull` / `push`) run
|
||||
//! entirely in-process through libgit2 (`git2`); there is no system
|
||||
//! `git` subprocess. Authentication is supplied through a
|
||||
//! [`git2::RemoteCallbacks`] credential closure built from the
|
||||
//! handle's stored auth carrier (see [`GitRepo::auth_env`] /
|
||||
//! [`GitRepo::with_auth_env`]); when the carrier is empty the closure
|
||||
//! falls back to the ambient credential helpers / ssh-agent, exactly
|
||||
//! the way the subprocess backend deferred to the user's git setup.
|
||||
//! The network ops are not unit-tested here (no network in tests);
|
||||
//! the remote-config readers / writers are.
|
||||
//!
|
||||
//! ## Auth carrier (libgit2 migration note)
|
||||
//!
|
||||
//! The subprocess backend carried auth as *git environment* pairs —
|
||||
//! `GIT_SSH_COMMAND`, `GIT_CONFIG_*`, `OP_GIT_HTTPS_*`. libgit2 takes
|
||||
//! credentials through a callback, not the environment, so the
|
||||
//! `auth_env` `Vec<(String, String)>` is repurposed as a small,
|
||||
//! structured credential carrier interpreted by [`build_callbacks`]:
|
||||
//!
|
||||
//! - `("token", "<username>:<token>")` → an HTTPS user/password
|
||||
//! credential ([`git2::Cred::userpass_plaintext`]).
|
||||
//! - `("ssh_key_path", "<private-key-path>")` → an SSH key credential
|
||||
//! ([`git2::Cred::ssh_key`]) honoring the `username_from_url`.
|
||||
//!
|
||||
//! The public surface — [`GitRepo::auth_env`] returning a
|
||||
//! `Vec<(String, String)>` and [`GitRepo::with_auth_env`] storing it —
|
||||
//! is unchanged; only the *meaning* of the pairs changed, and the
|
||||
//! `git_session` host wiring (`with_auth_env(repo.auth_env(..))`) keeps
|
||||
//! compiling and working without edits.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{git_output, stderr_of, GitError, GitRepo, MergeOutcome};
|
||||
use git2::{
|
||||
build::RepoBuilder, AutotagOption, Cred, CredentialType, FetchOptions, PushOptions,
|
||||
RemoteCallbacks,
|
||||
};
|
||||
|
||||
use crate::{GitError, GitRepo, MergeOutcome};
|
||||
|
||||
/// A configured git remote.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -20,32 +49,101 @@ pub struct Remote {
|
|||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// `git clone <url>` into `dir` and return a handle to the clone.
|
||||
/// `dir` must not already exist (git creates it).
|
||||
/// Clone `url` into `dir` and return a handle to the clone.
|
||||
/// `dir` must not already exist (libgit2 creates it).
|
||||
///
|
||||
/// Authentication uses the ambient credential helpers / ssh-agent
|
||||
/// — a fresh clone has no stored credential carrier yet (the
|
||||
/// returned handle starts with an empty `auth_env`, matching the
|
||||
/// subprocess backend, where the spawned `git clone` likewise
|
||||
/// inherited only the ambient git environment).
|
||||
pub fn clone(url: &str, dir: &Path) -> Result<GitRepo, GitError> {
|
||||
// Run from `dir`'s parent so a relative target resolves; the
|
||||
// parent must exist for git to create `dir` inside it.
|
||||
// The parent must exist for the clone target to be created
|
||||
// inside it; libgit2 (like `git clone`) creates only `dir`,
|
||||
// not its ancestors.
|
||||
let parent = dir.parent().unwrap_or_else(|| Path::new("."));
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| GitError::Io(e.to_string()))?;
|
||||
}
|
||||
let dir_str = dir
|
||||
.to_str()
|
||||
.ok_or_else(|| GitError::Io("non-UTF-8 path".into()))?;
|
||||
let output = git_output(parent, &["clone", url, dir_str])?;
|
||||
if !output.status.success() {
|
||||
return Err(GitError::Command {
|
||||
|
||||
// No stored credential yet — the clone authenticates through
|
||||
// the ambient credential helpers / ssh-agent (the closure
|
||||
// falls back to `Cred::credential_helper` / the ssh-agent when
|
||||
// the carrier is empty, exactly as the spawned `git clone`
|
||||
// inherited only the ambient git environment).
|
||||
let auth: Vec<(String, String)> = Vec::new();
|
||||
let callbacks = build_callbacks(&auth);
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
|
||||
let repo = RepoBuilder::new()
|
||||
.fetch_options(fetch_opts)
|
||||
.clone(url, dir)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "clone".to_string(),
|
||||
stderr: stderr_of(&output),
|
||||
});
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
|
||||
// Cloning an EMPTY remote leaves the local HEAD on libgit2's own
|
||||
// default branch name, which can differ from the remote's
|
||||
// configured initial branch (e.g. `main`). Match `git clone` by
|
||||
// pointing the still-unborn HEAD at the remote's advertised
|
||||
// default branch — falling back to `refs/heads/main` (the
|
||||
// initial-branch this crate's `init` uses) when the empty remote
|
||||
// advertises none — so the first local commit lands on a
|
||||
// predictable branch.
|
||||
if repo.is_empty().unwrap_or(false) {
|
||||
let target = repo
|
||||
.find_remote("origin")
|
||||
.ok()
|
||||
.and_then(|mut origin| {
|
||||
origin.connect(git2::Direction::Fetch).ok()?;
|
||||
let branch = origin
|
||||
.default_branch()
|
||||
.ok()
|
||||
.and_then(|b| b.as_str().map(str::to_string));
|
||||
let _ = origin.disconnect();
|
||||
branch
|
||||
})
|
||||
.unwrap_or_else(|| "refs/heads/main".to_string());
|
||||
let _ = repo.set_head(&target);
|
||||
}
|
||||
|
||||
GitRepo::discover(dir)?.ok_or_else(|| GitError::NotARepo(PathBuf::from(dir)))
|
||||
}
|
||||
|
||||
/// `git fetch` — update remote-tracking refs without touching the
|
||||
/// working tree.
|
||||
/// Fetch every remote — update remote-tracking refs without
|
||||
/// touching the working tree. Prunes deleted upstream branches,
|
||||
/// matching the former `git fetch --all --prune`.
|
||||
pub fn fetch(&self) -> Result<(), GitError> {
|
||||
self.run(&["fetch", "--all", "--prune"])?;
|
||||
let repo = self.open()?;
|
||||
// `git fetch --all` walks every configured remote, not just
|
||||
// `origin`; replicate that so a multi-remote repo behaves the
|
||||
// same as the subprocess backend.
|
||||
let remote_names = repo.remotes()?;
|
||||
for name in remote_names.iter().flatten() {
|
||||
let mut remote = repo.find_remote(name)?;
|
||||
// A fresh callback set per remote — the carrier is shared by
|
||||
// reference into each, so credentials are presented to every
|
||||
// remote uniformly. (The subprocess backend scoped its
|
||||
// credential helper per host; this carrier-based form
|
||||
// authenticates each remote with the same stored credential —
|
||||
// see the "Auth carrier" note for the behavioural delta.)
|
||||
let callbacks = build_callbacks(&self.auth_env);
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
fetch_opts.prune(git2::FetchPrune::On);
|
||||
fetch_opts.download_tags(AutotagOption::All);
|
||||
// Empty refspecs → use the remote's configured fetch
|
||||
// refspecs, exactly like a bare `git fetch <remote>`.
|
||||
let empty: &[&str] = &[];
|
||||
remote
|
||||
.fetch(empty, Some(&mut fetch_opts), None)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "fetch".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +151,9 @@ impl GitRepo {
|
|||
/// the [`MergeOutcome`] — exactly the TS `enginePull` model: a
|
||||
/// pull is a fetch followed by a merge of the remote-tracking
|
||||
/// ref. The fast-forward / merge / up-to-date decision is the
|
||||
/// shared, ancestry-based [`GitRepo::integrate`] classifier.
|
||||
/// shared, ancestry-based [`GitRepo::integrate`] classifier, which
|
||||
/// also surfaces [`GitError::WorkingTreeDirty`] / refuses while a
|
||||
/// merge is in progress.
|
||||
pub fn pull(&self) -> Result<MergeOutcome, GitError> {
|
||||
// Stop *before* any network work: a pull during an unresolved
|
||||
// merge is refused by `integrate` anyway, and fetching first
|
||||
|
|
@ -62,76 +162,175 @@ impl GitRepo {
|
|||
if self.is_merging() {
|
||||
return Err(GitError::MergeInProgress);
|
||||
}
|
||||
let before = self.run(&["rev-parse", "HEAD"])?.trim().to_string();
|
||||
// Resolve the pre-pull HEAD commit.
|
||||
let before = {
|
||||
let repo = self.open()?;
|
||||
let head = repo.head().map_err(|e| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
let oid = head.target().ok_or_else(|| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: "HEAD does not point at a commit".to_string(),
|
||||
})?;
|
||||
oid.to_string()
|
||||
};
|
||||
self.fetch()?;
|
||||
// `@{u}` is the configured upstream tracking ref; pulling
|
||||
// without one configured is a genuine error.
|
||||
let upstream = self.run(&["rev-parse", "@{u}"])?.trim().to_string();
|
||||
// The configured upstream tracking ref; pulling without one
|
||||
// configured is a genuine error (mirrors `git rev-parse @{u}`
|
||||
// failing).
|
||||
let upstream = self.upstream_oid()?;
|
||||
self.integrate(&before, &upstream)
|
||||
}
|
||||
|
||||
/// `git push` — publish the current branch to its upstream.
|
||||
/// Publish the current branch to its upstream.
|
||||
///
|
||||
/// When the branch already tracks an upstream this is a plain
|
||||
/// `git push`. When it does not, the push targets `origin` (or
|
||||
/// the sole configured remote) with `-u`, so the very first push
|
||||
/// also *sets* the upstream — the user never has to configure
|
||||
/// tracking by hand.
|
||||
/// `push` of `HEAD` to that upstream branch. When it does not, the
|
||||
/// push targets `origin` (or the sole configured remote) and also
|
||||
/// *sets* the upstream — the user never has to configure tracking
|
||||
/// by hand (the subprocess `push -u <remote> HEAD` behaviour).
|
||||
pub fn push(&self) -> Result<(), GitError> {
|
||||
if self.run(&["rev-parse", "--abbrev-ref", "@{u}"]).is_ok() {
|
||||
self.run(&["push"])?;
|
||||
return Ok(());
|
||||
}
|
||||
let remotes = self.remotes()?;
|
||||
let remote = remotes
|
||||
.iter()
|
||||
.find(|r| r.name == "origin")
|
||||
.or_else(|| remotes.first())
|
||||
.ok_or_else(|| GitError::Command {
|
||||
let repo = self.open()?;
|
||||
|
||||
// The current branch's short name and full ref — a push needs
|
||||
// an explicit `refs/heads/<branch>` refspec.
|
||||
let head = repo.head().map_err(|e| GitError::Command {
|
||||
operation: "push".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
if !head.is_branch() {
|
||||
return Err(GitError::Command {
|
||||
operation: "push".to_string(),
|
||||
stderr: "no remote configured — add one first".to_string(),
|
||||
stderr: "cannot push a detached HEAD — switch to a branch first".to_string(),
|
||||
});
|
||||
}
|
||||
let head_ref = head.name().ok_or_else(|| GitError::Command {
|
||||
operation: "push".to_string(),
|
||||
stderr: "HEAD has no symbolic name".to_string(),
|
||||
})?;
|
||||
let branch_short = head.shorthand().unwrap_or("HEAD").to_string();
|
||||
|
||||
// Does this branch already track an upstream? `git push` with
|
||||
// a configured upstream pushes there; otherwise we set it up.
|
||||
let tracked = repo.branch_upstream_name(head_ref).ok();
|
||||
|
||||
// Choose the target remote: the upstream's remote when one is
|
||||
// configured, else `origin`, else the sole remote.
|
||||
let remote_name = match repo.branch_upstream_remote(head_ref).ok() {
|
||||
Some(buf) => buf
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "origin".to_string()),
|
||||
None => {
|
||||
let remotes = self.remotes()?;
|
||||
remotes
|
||||
.iter()
|
||||
.find(|r| r.name == "origin")
|
||||
.or_else(|| remotes.first())
|
||||
.map(|r| r.name.clone())
|
||||
.ok_or_else(|| GitError::Command {
|
||||
operation: "push".to_string(),
|
||||
stderr: "no remote configured — add one first".to_string(),
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut remote = repo.find_remote(&remote_name)?;
|
||||
|
||||
// `HEAD:refs/heads/<branch>` publishes the current branch under
|
||||
// the same name on the remote — the conventional push refspec.
|
||||
let refspec = format!("{head_ref}:refs/heads/{branch_short}");
|
||||
|
||||
let callbacks = build_callbacks(&self.auth_env);
|
||||
let mut push_opts = PushOptions::new();
|
||||
push_opts.remote_callbacks(callbacks);
|
||||
|
||||
remote
|
||||
.push(&[refspec.as_str()], Some(&mut push_opts))
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "push".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
let remote_name = remote.name.clone();
|
||||
self.run(&["push", "-u", &remote_name, "HEAD"])?;
|
||||
|
||||
// First push of an untracked branch also sets up tracking, so
|
||||
// a later `push` / `pull` finds the upstream — the `-u` half
|
||||
// of the subprocess `push -u <remote> HEAD`.
|
||||
if tracked.is_none() {
|
||||
if let Ok(mut branch) = repo.find_branch(&branch_short, git2::BranchType::Local) {
|
||||
// `<remote>/<branch>` is the remote-tracking ref name.
|
||||
let upstream = format!("{remote_name}/{branch_short}");
|
||||
let _ = branch.set_upstream(Some(&upstream));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every configured remote with its fetch URL.
|
||||
pub fn remotes(&self) -> Result<Vec<Remote>, GitError> {
|
||||
let raw = self.run(&["remote", "-v"])?;
|
||||
Ok(parse_remotes(&raw))
|
||||
let repo = self.open()?;
|
||||
let names = repo.remotes()?;
|
||||
let mut remotes = Vec::new();
|
||||
for name in names.iter().flatten() {
|
||||
let Ok(remote) = repo.find_remote(name) else {
|
||||
continue;
|
||||
};
|
||||
// A remote with no fetch URL is degenerate; skip it rather
|
||||
// than emit an empty URL (`git remote -v` would not list a
|
||||
// `(fetch)` line for it either).
|
||||
let Some(url) = remote.url() else {
|
||||
continue;
|
||||
};
|
||||
remotes.push(Remote {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(remotes)
|
||||
}
|
||||
|
||||
/// The fetch URL of remote `name`, if it exists.
|
||||
pub fn remote_url(&self, name: &str) -> Result<Option<String>, GitError> {
|
||||
match self.run(&["remote", "get-url", name]) {
|
||||
Ok(url) => Ok(Some(url.trim().to_string())),
|
||||
// `git remote get-url` exits non-zero for an unknown
|
||||
// remote — that is "no such remote", not a hard error.
|
||||
Err(GitError::Command { .. }) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
let repo = self.open()?;
|
||||
// Bind the lookup to a local so the borrowed `Remote` (whose
|
||||
// `url()` borrows `repo`) is consumed before the block ends —
|
||||
// otherwise the temporary would outlive `repo`'s drop.
|
||||
let found = repo.find_remote(name);
|
||||
match found {
|
||||
Ok(remote) => Ok(remote.url().map(str::to_string)),
|
||||
// An unknown remote is "no such remote", not a hard error —
|
||||
// the same tolerance the subprocess backend gave a non-zero
|
||||
// `git remote get-url`.
|
||||
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point remote `name` at `url`, adding the remote when it does
|
||||
/// not exist yet.
|
||||
pub fn set_remote(&self, name: &str, url: &str) -> Result<(), GitError> {
|
||||
let repo = self.open()?;
|
||||
if self.remote_url(name)?.is_some() {
|
||||
self.run(&["remote", "set-url", name, url])?;
|
||||
repo.remote_set_url(name, url)?;
|
||||
} else {
|
||||
self.run(&["remote", "add", name, url])?;
|
||||
repo.remote(name, url)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// Resolve the git environment for an authenticated network op
|
||||
/// Resolve the credential carrier for an authenticated network op
|
||||
/// against the `origin` remote, using the credential + SSH-key
|
||||
/// stores. Returns env-var pairs to apply via
|
||||
/// stores. Returns carrier pairs to apply via
|
||||
/// [`GitRepo::with_auth_env`] — an empty `Vec` when no stored
|
||||
/// credential matches the remote's host, in which case git falls
|
||||
/// back to its ambient credential helpers / ssh-agent.
|
||||
/// credential matches the remote's host, in which case the network
|
||||
/// ops fall back to the ambient credential helpers / ssh-agent.
|
||||
///
|
||||
/// The returned pairs are interpreted by [`build_callbacks`]:
|
||||
/// `("ssh_key_path", <path>)` for SSH, `("token", "<user>:<tok>")`
|
||||
/// for HTTPS. They are no longer git environment variables — see
|
||||
/// the module-level "Auth carrier" note.
|
||||
pub fn auth_env(
|
||||
&self,
|
||||
auth: &crate::AuthStore,
|
||||
|
|
@ -149,76 +348,39 @@ impl GitRepo {
|
|||
};
|
||||
match credential {
|
||||
crate::Credential::Ssh { key_name } => match ssh.load(&key_name) {
|
||||
// Carry the private-key path; `build_callbacks` turns it
|
||||
// into a `Cred::ssh_key` honoring `username_from_url`.
|
||||
Ok(key) => vec![(
|
||||
"GIT_SSH_COMMAND".to_string(),
|
||||
// The key path is shell-quoted — `GIT_SSH_COMMAND`
|
||||
// is parsed shell-like by git, so an unescaped
|
||||
// path containing a quote / `$()` would otherwise
|
||||
// break out and run shell code.
|
||||
format!(
|
||||
"ssh -i {} -o IdentitiesOnly=yes",
|
||||
shell_single_quote(&key.private_path.display().to_string())
|
||||
),
|
||||
"ssh_key_path".to_string(),
|
||||
key.private_path.display().to_string(),
|
||||
)],
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
crate::Credential::Https { username, token } => {
|
||||
// A control character in the credential would corrupt
|
||||
// the line-based credential protocol — reject it
|
||||
// rather than emit a malformed (or unsafe) helper.
|
||||
// A control character in the credential cannot be
|
||||
// carried safely (it would corrupt a downstream
|
||||
// credential protocol if the carrier is ever spilled
|
||||
// back to git) — reject it, as the subprocess backend
|
||||
// did, rather than present a malformed credential.
|
||||
if has_control_char(&username) || has_control_char(&token) {
|
||||
return Vec::new();
|
||||
}
|
||||
// The credential-helper scope key must carry the
|
||||
// remote's port — git's credential URL match treats a
|
||||
// portless config URL as the default port, so a
|
||||
// non-standard-port HTTPS remote would otherwise miss.
|
||||
https_auth_env(&remote_authority(&url), username, token)
|
||||
// `<username>:<token>` — `build_callbacks` splits on the
|
||||
// first `:` into a `Cred::userpass_plaintext`. The `host`
|
||||
// pair scopes the token: `build_callbacks` only releases
|
||||
// it to a URL whose host matches, so a redirect to (or a
|
||||
// tampered origin pointing at) a different host can never
|
||||
// exfiltrate the PAT — preserving the host-scoping the
|
||||
// subprocess credential helper had.
|
||||
vec![
|
||||
("token".to_string(), format!("{username}:{token}")),
|
||||
("host".to_string(), host),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The git environment for an HTTPS-authenticated op against
|
||||
/// `authority` (a `host` or `host:port`).
|
||||
///
|
||||
/// The credential helper is registered **URL-scoped** —
|
||||
/// `credential.https://<authority>.helper`, not the bare
|
||||
/// `credential.helper` — so a multi-remote operation (`fetch --all`)
|
||||
/// can never present this token to a *different* remote. The
|
||||
/// `authority` keeps any explicit port because git's credential URL
|
||||
/// match is port-sensitive. The helper itself is a static shell
|
||||
/// snippet (no interpolated user data, so a crafted credential
|
||||
/// cannot inject shell code); the username / token reach it through
|
||||
/// dedicated env vars, emitted verbatim by `printf '%s'`.
|
||||
fn https_auth_env(authority: &str, username: String, token: String) -> Vec<(String, String)> {
|
||||
let helper = "!f() { printf '%s\\n' \
|
||||
\"username=$OP_GIT_HTTPS_USER\" \
|
||||
\"password=$OP_GIT_HTTPS_PASS\"; }; f";
|
||||
vec![
|
||||
("GIT_CONFIG_COUNT".to_string(), "1".to_string()),
|
||||
(
|
||||
"GIT_CONFIG_KEY_0".to_string(),
|
||||
format!("credential.https://{authority}.helper"),
|
||||
),
|
||||
("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()),
|
||||
("OP_GIT_HTTPS_USER".to_string(), username),
|
||||
("OP_GIT_HTTPS_PASS".to_string(), token),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether `s` holds an ASCII control character — a credential with
|
||||
/// one cannot be carried safely by git's line-based protocol.
|
||||
fn has_control_char(s: &str) -> bool {
|
||||
s.chars().any(|c| c.is_control())
|
||||
}
|
||||
|
||||
/// POSIX single-quote `s` so it survives shell word-splitting intact
|
||||
/// — every embedded `'` becomes `'\''`.
|
||||
fn shell_single_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// The host of the `origin` remote — `Some("github.com")` etc.
|
||||
/// `None` when there is no `origin` or its URL has no host.
|
||||
|
|
@ -227,20 +389,133 @@ impl GitRepo {
|
|||
let host = remote_host(&url);
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
/// The commit Oid (hex) of the current branch's configured
|
||||
/// upstream tracking ref. Errors — mapping to a
|
||||
/// [`GitError::Command`] — when no upstream is configured, mirroring
|
||||
/// the subprocess `git rev-parse @{u}` failure.
|
||||
fn upstream_oid(&self) -> Result<String, GitError> {
|
||||
let repo = self.open()?;
|
||||
let head = repo.head().map_err(|e| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
let head_ref = head.name().ok_or_else(|| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: "HEAD has no symbolic name".to_string(),
|
||||
})?;
|
||||
// `branch_upstream_name` yields the remote-tracking ref name
|
||||
// (`refs/remotes/origin/main`) for the branch's `@{u}`.
|
||||
let upstream_buf = repo
|
||||
.branch_upstream_name(head_ref)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
let upstream_ref = upstream_buf.as_str().ok_or_else(|| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: "upstream ref name is not valid UTF-8".to_string(),
|
||||
})?;
|
||||
let reference = repo
|
||||
.find_reference(upstream_ref)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
// Peel to the commit it points at — the upstream tip's Oid.
|
||||
let oid = reference
|
||||
.peel_to_commit()
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "rev-parse".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?
|
||||
.id();
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The authority of a git remote URL — `host` or `host:port`
|
||||
/// (bracketed for IPv6) — verbatim, port preserved. Used to build a
|
||||
/// port-sensitive `credential.https://<authority>` scope key. An
|
||||
/// scp-like remote carries no URL port, so its bare host is used.
|
||||
fn remote_authority(url: &str) -> String {
|
||||
let url = url.trim();
|
||||
if let Some(rest) = url.split("://").nth(1) {
|
||||
let after_user = rest.rsplit('@').next().unwrap_or(rest);
|
||||
after_user.split('/').next().unwrap_or("").to_string()
|
||||
} else {
|
||||
remote_host(url)
|
||||
}
|
||||
/// Build the [`RemoteCallbacks`] that authenticate a network op from
|
||||
/// the handle's stored credential carrier (`auth` — the repurposed
|
||||
/// `auth_env` `Vec`). The `.credentials` closure interprets the
|
||||
/// carrier keys:
|
||||
///
|
||||
/// - `("ssh_key_path", <path>)` → [`Cred::ssh_key`] for the
|
||||
/// `username_from_url` (defaulting to `git`).
|
||||
/// - `("token", "<user>:<tok>")` → [`Cred::userpass_plaintext`].
|
||||
///
|
||||
/// When the carrier holds nothing usable for the credential type
|
||||
/// libgit2 asks for, the closure falls back to
|
||||
/// [`Cred::credential_helper`] (the user's configured helpers) for
|
||||
/// HTTPS, [`Cred::ssh_key_from_agent`] for SSH, and finally
|
||||
/// [`Cred::default`] — so an un-authenticated handle behaves exactly
|
||||
/// like the subprocess backend deferring to the ambient git setup.
|
||||
fn build_callbacks<'a>(auth: &'a [(String, String)]) -> RemoteCallbacks<'a> {
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
callbacks.credentials(move |url, username_from_url, allowed| {
|
||||
// SSH key authentication — preferred when libgit2 asks for it
|
||||
// and we carry a key path.
|
||||
if allowed.contains(CredentialType::SSH_KEY) {
|
||||
let user = username_from_url.unwrap_or("git");
|
||||
if let Some(path) = carrier_value(auth, "ssh_key_path") {
|
||||
return Cred::ssh_key(user, None, Path::new(path), None);
|
||||
}
|
||||
// No stored key — let the agent answer (ssh-agent / Pageant),
|
||||
// matching the subprocess backend's ambient ssh-agent use.
|
||||
return Cred::ssh_key_from_agent(user);
|
||||
}
|
||||
|
||||
// HTTPS username/password (personal access token).
|
||||
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
|
||||
// Only release the stored token to the host it was scoped to.
|
||||
// libgit2 invokes this closure with the URL it is actually
|
||||
// authenticating against, which a redirect (or a tampered
|
||||
// remote) can change — without this check the PAT would be
|
||||
// sent to an attacker-controlled host.
|
||||
let host_ok = match carrier_value(auth, "host") {
|
||||
Some(expected) => &remote_host(url) == expected,
|
||||
// No host scoping recorded — be conservative and do not
|
||||
// release the token blindly; fall through to the helpers.
|
||||
None => false,
|
||||
};
|
||||
if host_ok {
|
||||
if let Some(pair) = carrier_value(auth, "token") {
|
||||
// `<username>:<token>` — split on the FIRST `:` so a
|
||||
// token containing `:` survives intact.
|
||||
let (user, token) = match pair.split_once(':') {
|
||||
Some((u, t)) => (u, t),
|
||||
None => ("", pair.as_str()),
|
||||
};
|
||||
return Cred::userpass_plaintext(user, token);
|
||||
}
|
||||
}
|
||||
// Fall back to the user's configured credential helpers
|
||||
// (osxkeychain / manager / store) for ambient HTTPS auth.
|
||||
let config = git2::Config::open_default()?;
|
||||
return Cred::credential_helper(&config, url, username_from_url);
|
||||
}
|
||||
|
||||
// libgit2 sometimes asks only for the SSH *username* before the
|
||||
// key exchange (e.g. an scp-like URL without `user@`).
|
||||
if allowed.contains(CredentialType::USERNAME) {
|
||||
return Cred::username(username_from_url.unwrap_or("git"));
|
||||
}
|
||||
|
||||
// Nothing matched — defer to the default credential (e.g. an
|
||||
// anonymous / already-authenticated transport).
|
||||
Cred::default()
|
||||
});
|
||||
callbacks
|
||||
}
|
||||
|
||||
/// The value of carrier key `key`, if present.
|
||||
fn carrier_value<'a>(auth: &'a [(String, String)], key: &str) -> Option<&'a String> {
|
||||
auth.iter().find(|(k, _)| k == key).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Whether `s` holds an ASCII control character — a credential with
|
||||
/// one cannot be carried safely.
|
||||
fn has_control_char(s: &str) -> bool {
|
||||
s.chars().any(|c| c.is_control())
|
||||
}
|
||||
|
||||
/// Extract the host from a git remote URL — `scheme://[user@]host/…`
|
||||
|
|
@ -289,51 +564,10 @@ fn remote_host(url: &str) -> String {
|
|||
authority.split(':').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Parse `git remote -v` output. Each remote prints a `(fetch)` and
|
||||
/// a `(push)` line; the `(fetch)` URL is kept, deduplicated by name.
|
||||
fn parse_remotes(raw: &str) -> Vec<Remote> {
|
||||
let mut remotes: Vec<Remote> = Vec::new();
|
||||
for line in raw.lines() {
|
||||
// Format: `<name>\t<url> (fetch|push)`.
|
||||
if !line.contains("(fetch)") {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let (Some(name), Some(url)) = (parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
if !remotes.iter().any(|r| r.name == name) {
|
||||
remotes.push(Remote {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
remotes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_fetch_urls_only_deduped() {
|
||||
let raw = "origin\tgit@github.com:ZSeven-W/openpencil.git (fetch)\n\
|
||||
origin\tgit@github.com:ZSeven-W/openpencil.git (push)\n\
|
||||
fork\thttps://example.com/x.git (fetch)\n\
|
||||
fork\thttps://example.com/x.git (push)\n";
|
||||
let remotes = parse_remotes(raw);
|
||||
assert_eq!(remotes.len(), 2);
|
||||
assert_eq!(remotes[0].name, "origin");
|
||||
assert_eq!(remotes[0].url, "git@github.com:ZSeven-W/openpencil.git");
|
||||
assert_eq!(remotes[1].name, "fork");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_remote_output_is_empty() {
|
||||
assert!(parse_remotes("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_host_parses_every_url_shape() {
|
||||
assert_eq!(remote_host("https://github.com/org/repo.git"), "github.com");
|
||||
|
|
@ -366,54 +600,27 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_escapes_embedded_quotes() {
|
||||
assert_eq!(shell_single_quote("/plain/key"), "'/plain/key'");
|
||||
assert_eq!(shell_single_quote("/a'b/key"), "'/a'\\''b/key'");
|
||||
fn has_control_char_flags_bad_credentials() {
|
||||
assert!(!has_control_char("alice"));
|
||||
assert!(!has_control_char("ghp_AbCdEf123456"));
|
||||
assert!(has_control_char("bad\ntoken"));
|
||||
assert!(has_control_char("bad\0token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_auth_env_scopes_the_helper_to_the_host() {
|
||||
let env = https_auth_env("github.com", "alice".into(), "tok".into());
|
||||
let key = &env.iter().find(|(k, _)| k == "GIT_CONFIG_KEY_0").unwrap().1;
|
||||
// URL-scoped — never the bare `credential.helper`, so a
|
||||
// `fetch --all` cannot leak this token to another remote.
|
||||
assert_eq!(key, "credential.https://github.com.helper");
|
||||
// The values travel as env vars, not interpolated.
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "OP_GIT_HTTPS_USER" && v == "alice"));
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "OP_GIT_HTTPS_PASS" && v == "tok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_auth_env_keeps_a_non_standard_port() {
|
||||
// git's credential URL match is port-sensitive — a portless
|
||||
// scope key would miss a `:8443` remote.
|
||||
let env = https_auth_env("gitlab.example.com:8443", "u".into(), "t".into());
|
||||
let key = &env.iter().find(|(k, _)| k == "GIT_CONFIG_KEY_0").unwrap().1;
|
||||
assert_eq!(key, "credential.https://gitlab.example.com:8443.helper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_authority_keeps_the_port() {
|
||||
fn carrier_value_finds_the_key() {
|
||||
let carrier = vec![
|
||||
("token".to_string(), "alice:secret".to_string()),
|
||||
(
|
||||
"ssh_key_path".to_string(),
|
||||
"/home/alice/.ssh/id".to_string(),
|
||||
),
|
||||
];
|
||||
assert_eq!(carrier_value(&carrier, "token").unwrap(), "alice:secret");
|
||||
assert_eq!(
|
||||
remote_authority("https://github.com/org/repo.git"),
|
||||
"github.com"
|
||||
);
|
||||
assert_eq!(
|
||||
remote_authority("https://gitlab.example.com:8443/x.git"),
|
||||
"gitlab.example.com:8443"
|
||||
);
|
||||
assert_eq!(
|
||||
remote_authority("https://user@gitlab.example.com:8443/x"),
|
||||
"gitlab.example.com:8443"
|
||||
);
|
||||
// scp-like carries no URL port — bare host.
|
||||
assert_eq!(
|
||||
remote_authority("git@github.com:org/repo.git"),
|
||||
"github.com"
|
||||
carrier_value(&carrier, "ssh_key_path").unwrap(),
|
||||
"/home/alice/.ssh/id"
|
||||
);
|
||||
assert!(carrier_value(&carrier, "missing").is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
//! Working-tree status, staging, commit and restore.
|
||||
//! Working-tree status, staging, commit and restore — in-process via
|
||||
//! libgit2 (`git2`), so no system `git` binary is required.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{stderr_of, GitError, GitRepo};
|
||||
use crate::{GitError, GitRepo};
|
||||
|
||||
/// How a file differs from `HEAD`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -65,268 +64,223 @@ impl GitRepo {
|
|||
/// Snapshot the working tree — branch, changed files, ahead /
|
||||
/// behind counts.
|
||||
pub fn status(&self) -> Result<RepoStatus, GitError> {
|
||||
let raw = self.run(&["status", "--porcelain=v1", "--branch"])?;
|
||||
Ok(parse_status(&raw))
|
||||
let repo = self.open()?;
|
||||
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true);
|
||||
let entries = repo.statuses(Some(&mut opts))?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in entries.iter() {
|
||||
let Some(path) = entry.path() else { continue };
|
||||
files.push(FileStatus {
|
||||
path: path.to_string(),
|
||||
state: classify(entry.status()),
|
||||
// Staged ⇔ the index differs from HEAD (any `INDEX_*` bit).
|
||||
staged: entry.status().intersects(
|
||||
git2::Status::INDEX_NEW
|
||||
| git2::Status::INDEX_MODIFIED
|
||||
| git2::Status::INDEX_DELETED
|
||||
| git2::Status::INDEX_RENAMED
|
||||
| git2::Status::INDEX_TYPECHANGE,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// `current_branch` resolves an unborn `HEAD` to its symbolic
|
||||
// target (`main`) too, so a fresh repo reports its branch rather
|
||||
// than a misleading detached state.
|
||||
let branch = self.current_branch().unwrap_or(None);
|
||||
let (ahead, behind) = ahead_behind(&repo);
|
||||
|
||||
Ok(RepoStatus {
|
||||
branch,
|
||||
files,
|
||||
ahead,
|
||||
behind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stage `paths` (relative to the repo root or absolute).
|
||||
/// Stage `paths` (relative to the repo root or absolute). Uses
|
||||
/// `add_all`, which stages additions, modifications AND deletions
|
||||
/// of the matched paths — mirroring `git add -- <path>`.
|
||||
pub fn stage(&self, paths: &[&Path]) -> Result<(), GitError> {
|
||||
if paths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut args: Vec<&str> = vec!["add", "--"];
|
||||
let path_strs: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
|
||||
args.extend(path_strs);
|
||||
self.run(&args)?;
|
||||
let repo = self.open()?;
|
||||
let mut index = repo.index()?;
|
||||
let specs: Vec<PathBuf> = paths.iter().map(|p| self.rel_to_workdir(p)).collect();
|
||||
index.add_all(specs.iter(), git2::IndexAddOption::DEFAULT, None)?;
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage every change in the working tree (`git add -A`).
|
||||
pub fn stage_all(&self) -> Result<(), GitError> {
|
||||
self.run(&["add", "-A"])?;
|
||||
let repo = self.open()?;
|
||||
let mut index = repo.index()?;
|
||||
// `add_all` over the whole tree picks up new + modified files;
|
||||
// `update_all` records deletions + modifications of already-tracked
|
||||
// files — together they equal `git add -A`.
|
||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
|
||||
index.update_all(["*"].iter(), None)?;
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unstage `paths` — remove them from the index without touching
|
||||
/// the working tree. After the first commit `git restore --staged`
|
||||
/// resets each path's index entry to `HEAD`; before any commit
|
||||
/// there is no `HEAD`, so `git rm --cached` drops the staged
|
||||
/// addition instead (leaving the file untracked).
|
||||
/// the working tree. After the first commit each path's index entry
|
||||
/// is reset to `HEAD`; before any commit there is no `HEAD`, so the
|
||||
/// staged addition is dropped from the index instead (leaving the
|
||||
/// file untracked).
|
||||
pub fn unstage(&self, paths: &[&Path]) -> Result<(), GitError> {
|
||||
if paths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let has_head = self
|
||||
.run(&["rev-parse", "--verify", "--quiet", "HEAD"])
|
||||
.is_ok();
|
||||
for path in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if has_head {
|
||||
self.run(&["restore", "--staged", "--", path])?;
|
||||
} else {
|
||||
self.run(&["rm", "--cached", "--quiet", "--", path])?;
|
||||
let repo = self.open()?;
|
||||
let specs: Vec<PathBuf> = paths.iter().map(|p| self.rel_to_workdir(p)).collect();
|
||||
match repo.head() {
|
||||
Ok(head) => {
|
||||
let head_obj = head.peel(git2::ObjectType::Commit)?;
|
||||
repo.reset_default(Some(&head_obj), specs.iter())?;
|
||||
}
|
||||
// Unborn HEAD (no commit yet) — drop the staged additions.
|
||||
Err(_) => {
|
||||
let mut index = repo.index()?;
|
||||
for spec in &specs {
|
||||
let _ = index.remove_path(spec);
|
||||
}
|
||||
index.write()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage a unified-diff `patch` into the index — `git apply
|
||||
/// --cached`, the mechanism behind per-hunk staging. `patch`
|
||||
/// must be a self-contained patch (file header + the chosen
|
||||
/// hunks). `--recount` lets git tolerate hunk line-count drift
|
||||
/// when only a subset of a file's hunks is applied.
|
||||
/// Stage a unified-diff `patch` into the index — the mechanism
|
||||
/// behind per-hunk staging. `patch` must be a self-contained patch
|
||||
/// (file header + the chosen hunks); it is applied to the index
|
||||
/// only (the working tree is untouched).
|
||||
pub fn apply_cached(&self, patch: &str) -> Result<(), GitError> {
|
||||
let mut child = Command::new("git")
|
||||
.current_dir(&self.workdir)
|
||||
.args(["apply", "--cached", "--recount", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
GitError::GitNotFound
|
||||
} else {
|
||||
GitError::Io(e.to_string())
|
||||
}
|
||||
})?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| GitError::Io("git apply stdin unavailable".to_string()))?
|
||||
.write_all(patch.as_bytes())
|
||||
.map_err(|e| GitError::Io(e.to_string()))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| GitError::Io(e.to_string()))?;
|
||||
if !output.status.success() {
|
||||
return Err(GitError::Command {
|
||||
operation: "apply".to_string(),
|
||||
stderr: stderr_of(&output),
|
||||
});
|
||||
}
|
||||
let repo = self.open()?;
|
||||
let diff = git2::Diff::from_buffer(patch.as_bytes())?;
|
||||
repo.apply(&diff, git2::ApplyLocation::Index, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `path` has a change staged in the index. `git diff
|
||||
/// --cached` lists the path exactly when its index entry differs
|
||||
/// from `HEAD` (or, before the first commit, from the empty
|
||||
/// tree) — the authoritative answer, unlike a UI snapshot.
|
||||
/// Whether `path` has a change staged in the index — its index
|
||||
/// entry differs from `HEAD` (or, before the first commit, from the
|
||||
/// empty tree).
|
||||
pub fn is_path_staged(&self, path: &str) -> Result<bool, GitError> {
|
||||
let out = self.run(&["diff", "--cached", "--name-only", "--", path])?;
|
||||
Ok(!out.trim().is_empty())
|
||||
let repo = self.open()?;
|
||||
let mut opts = git2::DiffOptions::new();
|
||||
opts.pathspec(self.rel_str(Path::new(path)));
|
||||
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
|
||||
let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
|
||||
Ok(diff.deltas().len() > 0)
|
||||
}
|
||||
|
||||
/// Commit the staged changes with `message`. Returns the new
|
||||
/// commit's full hash. Fails with [`GitError::Command`] when
|
||||
/// there is nothing staged to commit.
|
||||
/// commit's full hash. The committer identity comes from git config
|
||||
/// (`user.name` / `user.email`); an unborn `HEAD` produces the
|
||||
/// repository's first (parent-less) commit.
|
||||
pub fn commit(&self, message: &str) -> Result<String, GitError> {
|
||||
self.run(&["commit", "-m", message])?;
|
||||
Ok(self.run(&["rev-parse", "HEAD"])?.trim().to_string())
|
||||
let repo = self.open()?;
|
||||
let signature = repo.signature()?;
|
||||
|
||||
// Snapshot the staged index as a tree.
|
||||
let mut index = repo.index()?;
|
||||
let tree_oid = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_oid)?;
|
||||
|
||||
// Parent = the current HEAD commit, if the branch has one yet.
|
||||
let parents: Vec<git2::Commit> = match repo.head() {
|
||||
Ok(head) => head.peel_to_commit().ok().into_iter().collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
|
||||
|
||||
let oid = repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
message,
|
||||
&tree,
|
||||
&parent_refs,
|
||||
)?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
/// Restore `path`'s working-tree content to its version at
|
||||
/// `commit` (a hash, tag, branch, or `"HEAD"`).
|
||||
///
|
||||
/// This mirrors the TS engine's `restoreFileFromCommit`: it
|
||||
/// rewrites the working-tree file from the commit's blob and
|
||||
/// leaves the index untouched. Passing `"HEAD"` therefore
|
||||
/// discards uncommitted edits to the file; passing a historical
|
||||
/// commit hash rolls the file back to that revision.
|
||||
/// Restore `path`'s working-tree content to its version at `commit`
|
||||
/// (a hash, tag, branch, or `"HEAD"`), rewriting the working-tree
|
||||
/// file from the commit's blob and leaving the index untouched.
|
||||
pub fn restore(&self, path: &Path, commit: &str) -> Result<(), GitError> {
|
||||
let Some(path) = path.to_str() else {
|
||||
let repo = self.open()?;
|
||||
let rel = self.rel_to_workdir(path);
|
||||
let Some(rel_str) = rel.to_str() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.run(&["restore", "--source", commit, "--worktree", "--", path])?;
|
||||
let object = repo.revparse_single(&format!("{commit}:{rel_str}"))?;
|
||||
let blob = object.peel_to_blob()?;
|
||||
let abs = self.workdir().join(&rel);
|
||||
std::fs::write(&abs, blob.content()).map_err(|e| GitError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `git status --porcelain=v1 --branch` output.
|
||||
fn parse_status(raw: &str) -> RepoStatus {
|
||||
let mut branch = None;
|
||||
let mut ahead = 0;
|
||||
let mut behind = 0;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if let Some(rest) = line.strip_prefix("## ") {
|
||||
let (b, a, be) = parse_branch_line(rest);
|
||||
branch = b;
|
||||
ahead = a;
|
||||
behind = be;
|
||||
} else if line.len() >= 3 {
|
||||
files.push(parse_file_line(line));
|
||||
}
|
||||
/// A path made relative to the work-tree root — libgit2 index /
|
||||
/// pathspec APIs expect repo-relative paths, but callers pass
|
||||
/// absolute document paths.
|
||||
fn rel_to_workdir(&self, p: &Path) -> PathBuf {
|
||||
p.strip_prefix(self.workdir())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
RepoStatus {
|
||||
branch,
|
||||
files,
|
||||
ahead,
|
||||
behind,
|
||||
/// `rel_to_workdir` as a `String` for pathspec strings.
|
||||
fn rel_str(&self, p: &Path) -> String {
|
||||
self.rel_to_workdir(p).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `## ` branch header — `main...origin/main [ahead 1, behind 2]`.
|
||||
fn parse_branch_line(rest: &str) -> (Option<String>, u32, u32) {
|
||||
// The branch name runs up to `...` (upstream marker) or a space.
|
||||
let name_end = rest
|
||||
.find("...")
|
||||
.or_else(|| rest.find(' '))
|
||||
.unwrap_or(rest.len());
|
||||
let name = rest[..name_end].trim();
|
||||
// A brand-new repo with no commits reports `No commits yet on main`.
|
||||
let branch = if name.is_empty() || name.contains("No commits yet") {
|
||||
rest.rsplit(' ')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
};
|
||||
|
||||
let mut ahead = 0;
|
||||
let mut behind = 0;
|
||||
if let (Some(open), Some(close)) = (rest.find('['), rest.find(']')) {
|
||||
if open < close {
|
||||
for part in rest[open + 1..close].split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(n) = part.strip_prefix("ahead ") {
|
||||
ahead = n.trim().parse().unwrap_or(0);
|
||||
} else if let Some(n) = part.strip_prefix("behind ") {
|
||||
behind = n.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(branch, ahead, behind)
|
||||
}
|
||||
|
||||
/// Parse one `XY <path>` porcelain line into a [`FileStatus`].
|
||||
fn parse_file_line(line: &str) -> FileStatus {
|
||||
let code = &line[..2];
|
||||
let mut path = line[3..].to_string();
|
||||
// Renames render as `old -> new`; keep the destination path.
|
||||
if let Some(idx) = path.find(" -> ") {
|
||||
path = path[idx + 4..].to_string();
|
||||
}
|
||||
let x = code.as_bytes()[0] as char;
|
||||
let y = code.as_bytes()[1] as char;
|
||||
|
||||
let state = if code == "??" {
|
||||
ChangeState::Untracked
|
||||
} else if is_conflict(x, y) {
|
||||
/// Map a libgit2 status bitset to the single [`ChangeState`] the panel
|
||||
/// shows. Conflicts win; otherwise the first matching add / delete /
|
||||
/// rename / untracked classification, falling back to modified.
|
||||
fn classify(s: git2::Status) -> ChangeState {
|
||||
use git2::Status as St;
|
||||
if s.is_conflicted() {
|
||||
ChangeState::Conflicted
|
||||
} else if x == 'A' || y == 'A' {
|
||||
} else if s.intersects(St::WT_NEW) && !s.intersects(St::INDEX_NEW) {
|
||||
ChangeState::Untracked
|
||||
} else if s.intersects(St::INDEX_NEW) {
|
||||
ChangeState::Added
|
||||
} else if x == 'D' || y == 'D' {
|
||||
} else if s.intersects(St::INDEX_DELETED | St::WT_DELETED) {
|
||||
ChangeState::Deleted
|
||||
} else if x == 'R' || y == 'R' {
|
||||
} else if s.intersects(St::INDEX_RENAMED | St::WT_RENAMED) {
|
||||
ChangeState::Renamed
|
||||
} else {
|
||||
ChangeState::Modified
|
||||
}
|
||||
}
|
||||
|
||||
/// Ahead / behind commit counts of the current branch vs its configured
|
||||
/// upstream — `(0, 0)` when detached, unborn, or with no upstream set.
|
||||
fn ahead_behind(repo: &git2::Repository) -> (u32, u32) {
|
||||
let resolve = || -> Option<(usize, usize)> {
|
||||
let head = repo.head().ok()?;
|
||||
let local = head.target()?;
|
||||
let branch_name = head.shorthand()?;
|
||||
let upstream = repo
|
||||
.find_branch(branch_name, git2::BranchType::Local)
|
||||
.ok()?
|
||||
.upstream()
|
||||
.ok()?;
|
||||
let upstream_oid = upstream.get().target()?;
|
||||
repo.graph_ahead_behind(local, upstream_oid).ok()
|
||||
};
|
||||
// The index column (`x`) carries the change when it is staged.
|
||||
let staged = x != ' ' && x != '?';
|
||||
|
||||
FileStatus {
|
||||
path,
|
||||
state,
|
||||
staged,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an `XY` porcelain code marks an unresolved merge conflict.
|
||||
fn is_conflict(x: char, y: char) -> bool {
|
||||
x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_clean_branch_header() {
|
||||
let s = parse_status("## main\n");
|
||||
assert_eq!(s.branch.as_deref(), Some("main"));
|
||||
assert!(s.is_clean());
|
||||
assert_eq!((s.ahead, s.behind), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_upstream_ahead_behind() {
|
||||
let s = parse_status("## main...origin/main [ahead 2, behind 3]\n");
|
||||
assert_eq!(s.branch.as_deref(), Some("main"));
|
||||
assert_eq!((s.ahead, s.behind), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_each_porcelain_code() {
|
||||
let raw = "## main\n\
|
||||
M staged.txt\n\
|
||||
\u{20}M unstaged.txt\n\
|
||||
?? new.txt\n\
|
||||
A added.txt\n\
|
||||
\u{20}D gone.txt\n\
|
||||
UU conflict.txt\n";
|
||||
let s = parse_status(raw);
|
||||
assert_eq!(s.files.len(), 6);
|
||||
let by = |name: &str| s.files.iter().find(|f| f.path == name).unwrap().clone();
|
||||
assert_eq!(by("staged.txt").state, ChangeState::Modified);
|
||||
assert!(by("staged.txt").staged);
|
||||
assert_eq!(by("unstaged.txt").state, ChangeState::Modified);
|
||||
assert!(!by("unstaged.txt").staged);
|
||||
assert_eq!(by("new.txt").state, ChangeState::Untracked);
|
||||
assert_eq!(by("added.txt").state, ChangeState::Added);
|
||||
assert_eq!(by("gone.txt").state, ChangeState::Deleted);
|
||||
assert_eq!(by("conflict.txt").state, ChangeState::Conflicted);
|
||||
assert!(s.has_conflicts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_the_destination_path() {
|
||||
let s = parse_status("## main\nR old.txt -> new.txt\n");
|
||||
assert_eq!(s.files[0].path, "new.txt");
|
||||
assert_eq!(s.files[0].state, ChangeState::Renamed);
|
||||
}
|
||||
resolve()
|
||||
.map(|(a, b)| (a as u32, b as u32))
|
||||
.unwrap_or((0, 0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,145 @@ fn pull_classifies_up_to_date_then_fast_forward() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_refuses_to_fast_forward_over_a_dirty_tree() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let remote = unique_temp_dir("ff-dirty-remote");
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", "--initial-branch=main"])
|
||||
.arg(&remote)
|
||||
.output()
|
||||
.expect("init bare remote");
|
||||
|
||||
let (a_dir, a) = clone_for_test(&remote, "ff-dirty-a");
|
||||
std::fs::write(a_dir.join("a.op"), "1").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("init").unwrap();
|
||||
a.run(&["push", "-u", "origin", "main"]).unwrap();
|
||||
|
||||
let (b_dir, b) = clone_for_test(&remote, "ff-dirty-b");
|
||||
|
||||
// A publishes a new commit — B *could* fast-forward.
|
||||
std::fs::write(a_dir.join("a.op"), "2").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("update").unwrap();
|
||||
a.push().unwrap();
|
||||
|
||||
// B has an UNCOMMITTED edit to a tracked file. A fast-forward would
|
||||
// force-overwrite it, so the pull must refuse (WorkingTreeDirty)
|
||||
// rather than silently discard the local work — the data-loss
|
||||
// regression the libgit2 migration introduced + this guard fixes.
|
||||
std::fs::write(b_dir.join("a.op"), "local-uncommitted").unwrap();
|
||||
assert!(
|
||||
matches!(b.pull(), Err(GitError::WorkingTreeDirty)),
|
||||
"a fast-forward over a dirty tree must be refused, not forced"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b_dir.join("a.op")).unwrap(),
|
||||
"local-uncommitted",
|
||||
"the local uncommitted edit must survive the refused pull"
|
||||
);
|
||||
|
||||
for dir in [remote, a_dir, b_dir] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_refuses_to_fast_forward_over_a_colliding_untracked_file() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let remote = unique_temp_dir("ff-untracked-remote");
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", "--initial-branch=main"])
|
||||
.arg(&remote)
|
||||
.output()
|
||||
.expect("init bare remote");
|
||||
|
||||
let (a_dir, a) = clone_for_test(&remote, "ff-untracked-a");
|
||||
std::fs::write(a_dir.join("a.op"), "1").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("init").unwrap();
|
||||
a.run(&["push", "-u", "origin", "main"]).unwrap();
|
||||
|
||||
let (b_dir, b) = clone_for_test(&remote, "ff-untracked-b");
|
||||
|
||||
// A adds a NEW tracked file the fast-forward would bring down to B.
|
||||
std::fs::write(a_dir.join("new.op"), "from-remote").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("add new.op").unwrap();
|
||||
a.push().unwrap();
|
||||
|
||||
// B has an UNTRACKED file at that same path. A forced fast-forward
|
||||
// would clobber it, so the pull must refuse — the untracked-overwrite
|
||||
// data-loss case (git's "untracked working tree files would be
|
||||
// overwritten by merge").
|
||||
std::fs::write(b_dir.join("new.op"), "local-untracked").unwrap();
|
||||
assert!(
|
||||
matches!(b.pull(), Err(GitError::WorkingTreeDirty)),
|
||||
"a fast-forward that would overwrite an untracked file must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b_dir.join("new.op")).unwrap(),
|
||||
"local-untracked",
|
||||
"the untracked file must survive the refused pull"
|
||||
);
|
||||
|
||||
for dir in [remote, a_dir, b_dir] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_refuses_to_fast_forward_over_an_untracked_dir_replaced_by_a_file() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let remote = unique_temp_dir("ff-dir-remote");
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", "--initial-branch=main"])
|
||||
.arg(&remote)
|
||||
.output()
|
||||
.expect("init bare remote");
|
||||
|
||||
let (a_dir, a) = clone_for_test(&remote, "ff-dir-a");
|
||||
std::fs::write(a_dir.join("a.op"), "1").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("init").unwrap();
|
||||
a.run(&["push", "-u", "origin", "main"]).unwrap();
|
||||
|
||||
let (b_dir, b) = clone_for_test(&remote, "ff-dir-b");
|
||||
|
||||
// A adds a tracked FILE named `data` the fast-forward would bring down.
|
||||
std::fs::write(a_dir.join("data"), "from-remote").unwrap();
|
||||
a.stage_all().unwrap();
|
||||
a.commit("add data file").unwrap();
|
||||
a.push().unwrap();
|
||||
|
||||
// B has an untracked DIRECTORY at that path holding a local file. A
|
||||
// forced fast-forward would replace the directory with the incoming
|
||||
// file, destroying the local work — the file↔directory collision the
|
||||
// exact-path check missed; the pull must refuse.
|
||||
std::fs::create_dir(b_dir.join("data")).unwrap();
|
||||
std::fs::write(b_dir.join("data").join("local.op"), "local-work").unwrap();
|
||||
assert!(
|
||||
matches!(b.pull(), Err(GitError::WorkingTreeDirty)),
|
||||
"a fast-forward that replaces an untracked directory with a file must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b_dir.join("data").join("local.op")).unwrap(),
|
||||
"local-work",
|
||||
"the untracked directory's file must survive the refused pull"
|
||||
);
|
||||
|
||||
for dir in [remote, a_dir, b_dir] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_classifies_a_divergent_merge() {
|
||||
if !git_available() {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@
|
|||
//! conflicting one is reported as a [`crate::ConflictBag`] without
|
||||
//! ever marking up the user's files. The worktree directory and its
|
||||
//! git registration are removed when the handle drops.
|
||||
//!
|
||||
//! The implementation drives libgit2 in-process (`git2`) — no system
|
||||
//! `git` subprocess. A linked worktree is created with
|
||||
//! [`git2::Repository::worktree`], detached onto the requested commit
|
||||
//! by opening the worktree repo and `set_head_detached` + a forced
|
||||
//! `checkout_head`, and deregistered on drop via
|
||||
//! [`git2::Worktree::prune`] with `working_tree` enabled so libgit2
|
||||
//! removes both the `.git/worktrees/<name>` admin files and the
|
||||
//! worktree directory itself.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -26,20 +35,83 @@ pub(crate) struct MergeWorktree {
|
|||
main: GitRepo,
|
||||
/// The worktree directory.
|
||||
dir: PathBuf,
|
||||
/// libgit2's registration name for this linked worktree (the
|
||||
/// directory basename). Used to look the worktree up again on
|
||||
/// drop so it can be pruned.
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl MergeWorktree {
|
||||
/// Add a detached worktree at `dir` checked out at `commit`.
|
||||
/// `dir` must not already exist — `git` creates it.
|
||||
/// `dir` must not already exist — libgit2 creates it.
|
||||
pub(crate) fn create(
|
||||
main: &GitRepo,
|
||||
dir: PathBuf,
|
||||
commit: &str,
|
||||
) -> Result<MergeWorktree, GitError> {
|
||||
let dir_str = dir
|
||||
.to_str()
|
||||
.ok_or_else(|| GitError::Io("worktree path is not valid UTF-8".to_string()))?;
|
||||
main.run(&["worktree", "add", "--detach", dir_str, commit])?;
|
||||
// The worktree registration name. libgit2 keys its admin
|
||||
// files under `.git/worktrees/<name>`; deriving it from the
|
||||
// directory basename keeps it unique (the dir is itself a
|
||||
// unique temp path) and lets `Drop` find the worktree again.
|
||||
let name = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| GitError::Io("worktree path has no valid basename".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let main_repo = main.open()?;
|
||||
|
||||
// Resolve the committish (a full HEAD sha in practice, but
|
||||
// accept any revspec) to the commit it names. The detached
|
||||
// HEAD is pinned onto exactly this commit below.
|
||||
let commit_oid = main_repo
|
||||
.revparse_single(commit)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?
|
||||
.peel_to_commit()
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?
|
||||
.id();
|
||||
|
||||
// Create the linked worktree. With no `reference` set in the
|
||||
// options libgit2 checks it out at the main repo's HEAD; we
|
||||
// re-point it onto `commit` (detached) immediately after.
|
||||
let opts = git2::WorktreeAddOptions::new();
|
||||
let worktree =
|
||||
main_repo
|
||||
.worktree(&name, &dir, Some(&opts))
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
|
||||
// Detach the new worktree's HEAD onto the requested commit and
|
||||
// force the working tree to match — the libgit2 equivalent of
|
||||
// `git worktree add --detach <dir> <commit>`.
|
||||
let wt_repo =
|
||||
git2::Repository::open_from_worktree(&worktree).map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
wt_repo
|
||||
.set_head_detached(commit_oid)
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force();
|
||||
wt_repo
|
||||
.checkout_head(Some(&mut checkout))
|
||||
.map_err(|e| GitError::Command {
|
||||
operation: "worktree add".to_string(),
|
||||
stderr: e.message().to_string(),
|
||||
})?;
|
||||
|
||||
Ok(MergeWorktree {
|
||||
repo: GitRepo {
|
||||
workdir: dir.clone(),
|
||||
|
|
@ -47,6 +119,7 @@ impl MergeWorktree {
|
|||
},
|
||||
main: main.clone(),
|
||||
dir,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -59,15 +132,33 @@ impl MergeWorktree {
|
|||
|
||||
impl Drop for MergeWorktree {
|
||||
fn drop(&mut self) {
|
||||
// Deregister the worktree with git first so its admin files
|
||||
// under `.git/worktrees/` are cleaned up.
|
||||
if let Some(dir_str) = self.dir.to_str() {
|
||||
let _ = self.main.run(&["worktree", "remove", "--force", dir_str]);
|
||||
// Deregister the worktree with libgit2 first so its admin
|
||||
// files under `.git/worktrees/` are cleaned up. `prune` with
|
||||
// `working_tree` also recursively removes the worktree
|
||||
// directory itself.
|
||||
if let Ok(main_repo) = self.main.open() {
|
||||
if let Ok(worktree) = main_repo.find_worktree(&self.name) {
|
||||
let mut opts = git2::WorktreePruneOptions::new();
|
||||
// Prune even though the worktree is still valid (it
|
||||
// exists on disk) and recursively remove its working
|
||||
// directory — this is a throwaway tree by design.
|
||||
opts.valid(true).working_tree(true);
|
||||
let _ = worktree.prune(Some(&mut opts));
|
||||
}
|
||||
}
|
||||
// Belt-and-suspenders: if `git worktree remove` failed (e.g.
|
||||
// a half-created worktree), still drop the directory and
|
||||
// prune the now-dangling registration.
|
||||
// Belt-and-suspenders: if the prune failed or only removed the
|
||||
// admin files (e.g. a half-created worktree, or a libgit2
|
||||
// build that left the directory behind), still drop the
|
||||
// directory so no stale temp tree lingers.
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
let _ = self.main.run(&["worktree", "prune"]);
|
||||
// Final sweep: drop any now-dangling registration whose
|
||||
// working tree no longer exists on disk.
|
||||
if let Ok(main_repo) = self.main.open() {
|
||||
if let Ok(worktree) = main_repo.find_worktree(&self.name) {
|
||||
if worktree.is_prunable(None).unwrap_or(false) {
|
||||
let _ = worktree.prune(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue