Implement embedded Git synchronization TUI

This commit is contained in:
Hermes Agent
2026-08-10 10:39:31 +00:00
parent ce3e4a9f79
commit 1850846696
8 changed files with 1402 additions and 36 deletions

View File

@@ -71,6 +71,18 @@ impl Config {
&self.git_remotes
}
/// Select a configured remote by name, or the configured default (first
/// remote) when no name was requested.
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
match requested {
Some(name) => self
.git_remotes
.iter()
.find(|remote| remote.name().as_str() == name),
None => self.git_remotes.first(),
}
}
/// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`.
pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> {
self.resolve_editor_from(

View File

@@ -9,6 +9,10 @@ use std::{
error::Error,
fmt, fs,
path::{Component, Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use flate2::{Compression, write::ZlibEncoder};
@@ -102,6 +106,153 @@ pub struct GitStatus {
unstaged: Vec<GitChange>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitConflictKind {
Content,
AddAdd,
ModifyDelete,
Structural,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitConflict {
path: PathBuf,
kind: GitConflictKind,
}
impl GitConflict {
pub fn path(&self) -> &Path {
&self.path
}
pub fn kind(&self) -> GitConflictKind {
self.kind
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitConflictChoice {
Local,
Remote,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitConflictResolution {
path: PathBuf,
choice: GitConflictChoice,
}
impl GitConflictResolution {
pub fn new(path: PathBuf, choice: GitConflictChoice) -> Self {
Self { path, choice }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRemoteStatus {
name: String,
url: String,
ahead: usize,
behind: usize,
}
impl GitRemoteStatus {
pub fn name(&self) -> &str {
&self.name
}
pub fn url(&self) -> &str {
&self.url
}
pub fn ahead(&self) -> usize {
self.ahead
}
pub fn behind(&self) -> usize {
self.behind
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitSnapshot {
root: PathBuf,
branch: String,
status: GitStatus,
remote: Option<GitRemoteStatus>,
recent: Vec<GitLogEntry>,
}
impl GitSnapshot {
pub fn root(&self) -> &Path {
&self.root
}
pub fn branch(&self) -> &str {
&self.branch
}
pub fn status(&self) -> &GitStatus {
&self.status
}
pub fn remote(&self) -> Option<&GitRemoteStatus> {
self.remote.as_ref()
}
pub fn recent(&self) -> &[GitLogEntry] {
&self.recent
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitProgressPhase {
Validating,
Authenticating,
Receiving,
Integrating,
Sending,
Refreshing,
}
#[derive(Clone)]
pub struct GitOperationControl {
cancelled: Arc<AtomicBool>,
progress: Arc<dyn Fn(GitProgressPhase) + Send + Sync>,
}
impl Default for GitOperationControl {
fn default() -> Self {
Self::new(|_| {})
}
}
impl GitOperationControl {
pub fn new(progress: impl Fn(GitProgressPhase) + Send + Sync + 'static) -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
progress: Arc::new(progress),
}
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
fn checkpoint(&self, phase: GitProgressPhase) -> Result<(), GitError> {
if self.is_cancelled() {
return Err(GitError::Cancelled);
}
(self.progress)(phase);
if self.is_cancelled() {
Err(GitError::Cancelled)
} else {
Ok(())
}
}
pub fn report(&self, phase: GitProgressPhase) -> Result<(), GitError> {
self.checkpoint(phase)
}
}
impl GitStatus {
pub fn staged(&self) -> &[GitChange] {
&self.staged
@@ -193,9 +344,15 @@ pub enum GitError {
CredentialAccessDenied,
CredentialCancelled,
AuthenticationFailed,
NetworkUnavailable,
TlsFailed,
Cancelled,
NonFastForward,
MergeConflicts {
paths: Vec<PathBuf>,
conflicts: Vec<GitConflict>,
},
InvalidConflictResolution {
path: PathBuf,
},
InvalidRepository(String),
Io {
@@ -238,11 +395,19 @@ impl fmt::Display for GitError {
formatter.write_str("HTTPS Git credential authentication was cancelled")
}
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
Self::NetworkUnavailable => formatter.write_str("the HTTPS Git remote is unavailable"),
Self::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"),
Self::Cancelled => formatter.write_str("the Git operation was cancelled"),
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
Self::MergeConflicts { paths } => write!(
Self::MergeConflicts { conflicts } => write!(
formatter,
"the merge has conflicts in {} path(s)",
paths.len()
conflicts.len()
),
Self::InvalidConflictResolution { path } => write!(
formatter,
"no supported merge conflict exists at {}",
path.display()
),
Self::InvalidRepository(message) => {
write!(formatter, "invalid Git repository: {message}")
@@ -408,6 +573,31 @@ pub trait GitSmartHttpTransport {
credential: &GitCredential,
request: Vec<u8>,
) -> Result<Vec<u8>, GitError>;
fn advertise_receive_pack_controlled(
&self,
url: &url::Url,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<Vec<u8>, GitError> {
control.checkpoint(GitProgressPhase::Receiving)?;
let result = self.advertise_receive_pack(url, credential)?;
control.checkpoint(GitProgressPhase::Receiving)?;
Ok(result)
}
fn receive_pack_controlled(
&self,
url: &url::Url,
credential: &GitCredential,
request: Vec<u8>,
control: &GitOperationControl,
) -> Result<Vec<u8>, GitError> {
control.checkpoint(GitProgressPhase::Sending)?;
let result = self.receive_pack(url, credential, request)?;
control.checkpoint(GitProgressPhase::Sending)?;
Ok(result)
}
}
pub trait GitFetchTransport {
@@ -417,6 +607,19 @@ pub trait GitFetchTransport {
configured: &GitRemote,
credential: &GitCredential,
) -> Result<bool, GitError>;
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<bool, GitError> {
control.checkpoint(GitProgressPhase::Receiving)?;
let result = self.fetch(repository, configured, credential)?;
control.checkpoint(GitProgressPhase::Receiving)?;
Ok(result)
}
}
#[derive(Default)]
@@ -431,6 +634,16 @@ impl GitFetchTransport for EmbeddedFetchTransport {
) -> Result<bool, GitError> {
repository.fetch_embedded(configured, credential)
}
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<bool, GitError> {
repository.fetch_embedded_controlled(configured, credential, control)
}
}
#[derive(Default)]
@@ -473,7 +686,7 @@ impl ReqwestGitTransport {
response
.bytes()
.map(|bytes| bytes.to_vec())
.map_err(invalid)
.map_err(map_reqwest_error)
}
}
@@ -493,7 +706,7 @@ impl GitSmartHttpTransport for ReqwestGitTransport {
Self::response(
Self::authenticated(request, credential)?
.send()
.map_err(invalid)?,
.map_err(map_reqwest_error)?,
)
}
@@ -512,11 +725,23 @@ impl GitSmartHttpTransport for ReqwestGitTransport {
Self::response(
Self::authenticated(request, credential)?
.send()
.map_err(invalid)?,
.map_err(map_reqwest_error)?,
)
}
}
fn map_reqwest_error(error: reqwest::Error) -> GitError {
if error.is_builder() {
return invalid(error);
}
let message = error.to_string().to_ascii_lowercase();
if message.contains("certificate") || message.contains("tls") {
GitError::TlsFailed
} else {
GitError::NetworkUnavailable
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FetchOutcome {
remote: String,
@@ -877,14 +1102,31 @@ impl GitRepository {
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport_controlled(
configured,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn fetch_with_transport_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<FetchOutcome, GitError> {
control.checkpoint(GitProgressPhase::Validating)?;
let name = configured.name().as_str();
let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() {
return Err(GitError::ForbiddenRemoteUrl);
}
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential =
credentials.credential(configured.server_id(), configured.application_id())?;
let received_pack = transport.fetch(self, configured, &credential)?;
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
Ok(FetchOutcome {
remote: name.to_owned(),
received_pack,
@@ -899,6 +1141,19 @@ impl GitRepository {
&self,
configured: &GitRemote,
credential: &GitCredential,
) -> Result<bool, GitError> {
self.fetch_embedded_controlled(configured, credential, &GitOperationControl::default())
}
#[allow(
clippy::result_large_err,
reason = "the gix credential callback fixes its protocol error type"
)]
fn fetch_embedded_controlled(
&self,
configured: &GitRemote,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<bool, GitError> {
let name = configured.name().as_str();
let password = std::str::from_utf8(credential.password())
@@ -940,17 +1195,26 @@ impl GitRepository {
.prepare_fetch(gix::progress::Discard, Default::default())
.map_err(invalid)?;
let outcome = prepared
.receive(
gix::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
)
.receive(gix::progress::Discard, control.cancelled.as_ref())
.map_err(|error| {
if control.is_cancelled() {
return GitError::Cancelled;
}
let text = error.to_string();
if text.contains("401")
|| text.contains("403")
|| text.to_ascii_lowercase().contains("authentication")
{
GitError::AuthenticationFailed
} else if text.to_ascii_lowercase().contains("certificate")
|| text.to_ascii_lowercase().contains("tls")
{
GitError::TlsFailed
} else if text.to_ascii_lowercase().contains("network")
|| text.to_ascii_lowercase().contains("connect")
|| text.to_ascii_lowercase().contains("dns")
{
GitError::NetworkUnavailable
} else {
invalid(error)
}
@@ -976,11 +1240,29 @@ impl GitRepository {
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<PullOutcome, GitError> {
self.pull_with_transport_controlled(
configured,
branch,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn pull_with_transport_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<PullOutcome, GitError> {
if !self.status()?.is_clean() {
return Err(GitError::DirtyWorktree);
}
self.fetch_with_transport(configured, credentials, transport)?;
self.fetch_with_transport_controlled(configured, credentials, transport, control)?;
control.checkpoint(GitProgressPhase::Integrating)?;
self.integrate_fetched(configured, branch)
}
@@ -1049,16 +1331,16 @@ impl GitRepository {
.map_err(invalid)?;
let unresolved = gix::merge::tree::TreatAsUnresolved::default();
if outcome.tree_merge.has_unresolved_conflicts(unresolved) {
let mut paths = outcome
let mut conflicts = outcome
.tree_merge
.conflicts
.iter()
.filter(|conflict| conflict.is_unresolved(unresolved))
.map(|conflict| PathBuf::from(conflict.ours.location().to_str_lossy().as_ref()))
.map(conflict_description)
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
return Err(GitError::MergeConflicts { paths });
conflicts.sort_by(|left, right| left.path.cmp(&right.path));
conflicts.dedup_by(|left, right| left.path == right.path);
return Err(GitError::MergeConflicts { conflicts });
}
let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach();
let signature = self.identity.signature();
@@ -1098,6 +1380,24 @@ impl GitRepository {
credentials: &impl GitCredentialProvider,
transport: &impl GitSmartHttpTransport,
) -> Result<PushOutcome, GitError> {
self.push_with_transport_controlled(
configured,
branch,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn push_with_transport_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitSmartHttpTransport,
control: &GitOperationControl,
) -> Result<PushOutcome, GitError> {
control.checkpoint(GitProgressPhase::Validating)?;
if !self.status()?.is_clean() {
return Err(GitError::DirtyWorktree);
}
@@ -1120,9 +1420,11 @@ impl GitRepository {
.head_id()
.map_err(|_| GitError::UnbornHead)?
.detach();
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential =
credentials.credential(configured.server_id(), configured.application_id())?;
let advertisement = transport.advertise_receive_pack(&url, &credential)?;
let advertisement =
transport.advertise_receive_pack_controlled(&url, &credential, control)?;
let advertised = parse_receive_pack_advertisement(&advertisement)?;
let old = advertised.refs.get(&reference).copied();
if let Some(old) = old {
@@ -1160,8 +1462,9 @@ impl GitRepository {
let mut request = encode_pkt_line(command.as_bytes())?;
request.extend_from_slice(b"0000");
request.extend_from_slice(&pack);
let response = transport.receive_pack(&url, &credential, request)?;
let response = transport.receive_pack_controlled(&url, &credential, request, control)?;
parse_receive_pack_result(&response, &reference)?;
self.update_remote_tracking(name, &branch, new)?;
Ok(PushOutcome {
remote: name.to_owned(),
branch,
@@ -1180,7 +1483,243 @@ impl GitRepository {
Ok((pull, push))
}
fn current_branch(&self) -> Result<String, GitError> {
/// Return one internally consistent, secret-free view of repository state
/// for frontends. The remote relation uses only the last fetched tracking
/// ref and therefore never performs network access.
pub fn snapshot(
&self,
configured: Option<&GitRemote>,
recent_limit: usize,
) -> Result<GitSnapshot, GitError> {
let branch = self.current_branch()?;
let status = self.status()?;
let recent = match self.log(Some(recent_limit)) {
Ok(entries) => entries,
Err(GitError::UnbornHead) => Vec::new(),
Err(error) => return Err(error),
};
let remote = configured
.map(|configured| {
let name = configured.name().as_str();
let actual_url = if self.remotes().iter().any(|remote| remote == name) {
let actual = self.remote_url(name)?;
if actual != configured.url().as_str() {
return Err(GitError::ForbiddenRemoteUrl);
}
actual
} else {
configured.url().to_string()
};
let remote_ref = format!("refs/remotes/{name}/{branch}");
let relation = self
.repository
.find_reference(&remote_ref)
.ok()
.and_then(|reference| reference.into_fully_peeled_id().ok())
.and_then(|remote_id| {
self.repository
.head_id()
.ok()
.map(|local_id| (local_id.detach(), remote_id.detach()))
});
let (ahead, behind) = relation.map_or(Ok((0, 0)), |(local, remote)| {
self.ahead_behind(local, remote)
})?;
Ok(GitRemoteStatus {
name: name.to_owned(),
url: actual_url,
ahead,
behind,
})
})
.transpose()?;
Ok(GitSnapshot {
root: self.root.clone(),
branch,
status,
remote,
recent,
})
}
fn ahead_behind(
&self,
local: gix::hash::ObjectId,
remote: gix::hash::ObjectId,
) -> Result<(usize, usize), GitError> {
let ancestors = |id| -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
let commit = self
.repository
.find_object(id)
.map_err(invalid)?
.peel_to_commit()
.map_err(invalid)?;
let mut ids = BTreeSet::from([id]);
for info in commit.ancestors().all().map_err(invalid)? {
ids.insert(info.map_err(invalid)?.id);
}
Ok(ids)
};
let local_ids = ancestors(local)?;
let remote_ids = ancestors(remote)?;
Ok((
local_ids.difference(&remote_ids).count(),
remote_ids.difference(&local_ids).count(),
))
}
fn update_remote_tracking(
&self,
remote: &str,
branch: &str,
id: gix::hash::ObjectId,
) -> Result<(), GitError> {
use gix::refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
};
let name = gix::refs::FullName::try_from(format!("refs/remotes/{remote}/{branch}"))
.map_err(invalid)?;
let edit = RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "push: update remote-tracking branch".into(),
},
expected: PreviousValue::Any,
new: Target::Object(id),
},
name,
deref: false,
};
let signature = self.identity.signature();
let mut time = gix::date::parse::TimeBuf::default();
self.repository
.edit_references_as(Some(edit), Some(signature.to_ref(&mut time)))
.map_err(invalid)?;
Ok(())
}
/// Resolve every conflict in the current fetched merge using explicit
/// whole-path local/remote choices. No repository state is changed unless
/// all choices are valid and the merge commit and checkout both succeed.
pub fn resolve_fetched(
&self,
configured: &GitRemote,
branch: Option<&str>,
resolutions: &[GitConflictResolution],
) -> Result<PullOutcome, GitError> {
if !self.status()?.is_clean() {
return Err(GitError::DirtyWorktree);
}
let branch = branch.map_or_else(
|| self.current_branch(),
|branch| {
validate_remote_name(branch)?;
Ok(branch.to_owned())
},
)?;
let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name());
let remote_id = self
.repository
.find_reference(&remote_ref_name)
.map_err(|_| GitError::RemoteNotFound {
name: remote_ref_name.clone(),
})?
.into_fully_peeled_id()
.map_err(invalid)?
.detach();
let local_id = self
.repository
.head_id()
.map_err(|_| GitError::UnbornHead)?
.detach();
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: Some("base".into()),
current: Some("HEAD".into()),
other: Some(configured.name().as_str().into()),
};
let options = self
.repository
.tree_merge_options()
.map_err(invalid)?
.into();
let mut outcome = self
.repository
.merge_commits(local_id, remote_id, labels, options)
.map_err(invalid)?;
let unresolved = gix::merge::tree::TreatAsUnresolved::default();
let choices = resolutions
.iter()
.map(|resolution| (resolution.path.clone(), resolution.choice))
.collect::<BTreeMap<_, _>>();
let conflicts = outcome
.tree_merge
.conflicts
.iter()
.filter(|conflict| conflict.is_unresolved(unresolved))
.collect::<Vec<_>>();
for conflict in &conflicts {
let description = conflict_description(conflict);
let choice = choices.get(&description.path).ok_or_else(|| {
GitError::InvalidConflictResolution {
path: description.path.clone(),
}
})?;
let entries = conflict.entries();
let selected = match choice {
GitConflictChoice::Local => entries[1],
GitConflictChoice::Remote => entries[2],
};
let path = path_to_git(&description.path)?;
let _ = outcome.tree_merge.tree.remove(path.as_bstr());
if let Some(entry) = selected {
outcome
.tree_merge
.tree
.upsert(path.as_bstr(), entry.mode.kind(), entry.id)
.map_err(invalid)?;
}
}
if choices.len() != conflicts.len() {
let extra = choices
.keys()
.find(|path| {
!conflicts
.iter()
.any(|conflict| conflict_description(conflict).path == **path)
})
.cloned()
.unwrap_or_default();
return Err(GitError::InvalidConflictResolution { path: extra });
}
if conflicts.is_empty() {
return self.integrate_fetched(configured, Some(&branch));
}
let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach();
let signature = self.identity.signature();
let mut committer_time = gix::date::parse::TimeBuf::default();
let mut author_time = gix::date::parse::TimeBuf::default();
let commit = self
.repository
.new_commit_as(
signature.to_ref(&mut committer_time),
signature.to_ref(&mut author_time),
format!(
"Merge remote-tracking branch '{}/{}'.",
configured.name(),
branch
),
tree,
[local_id, remote_id],
)
.map_err(invalid)?;
self.checkout_and_update(commit.id, Some(local_id))?;
Ok(PullOutcome::Merged)
}
pub fn current_branch(&self) -> Result<String, GitError> {
let name = self
.repository
.head_name()
@@ -1782,6 +2321,30 @@ fn diff_contents(
}
}
fn conflict_description(conflict: &gix::merge::tree::Conflict) -> GitConflict {
let entries = conflict.entries();
let kind = match (
entries[0].is_some(),
entries[1].is_some(),
entries[2].is_some(),
) {
(false, true, true) => GitConflictKind::AddAdd,
(true, false, true) | (true, true, false) => GitConflictKind::ModifyDelete,
(true, true, true) if conflict.content_merge().is_some() => GitConflictKind::Content,
_ => GitConflictKind::Structural,
};
let (ours, theirs) = conflict.changes_in_resolution();
let location = if ours.location().is_empty() {
theirs.location()
} else {
ours.location()
};
GitConflict {
path: PathBuf::from(location.to_str_lossy().as_ref()),
kind,
}
}
fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
if contents.is_empty() {
return;

View File

@@ -2,15 +2,22 @@
mod support;
use std::{error::Error, fs, io::Cursor, path::Path, sync::Mutex};
use std::{
error::Error,
fs,
io::Cursor,
path::Path,
sync::{Arc, Mutex},
};
use ironstorage::{
command::{InsertInput, InsertRequest},
config::{Config, ConfigLoader, GitRemote},
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{
GitChangeKind, GitCredential, GitCredentialProvider, GitError, GitFetchTransport,
GitIdentity, GitRepository, GitSmartHttpTransport, PullOutcome,
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome,
},
repository::{Repository, SecretBytes},
write::{InsertContent, OverwriteDecision, VaultWriter},
@@ -206,6 +213,19 @@ impl GitFetchTransport for NoopFetch {
}
}
struct FailedFetch(GitError);
impl GitFetchTransport for FailedFetch {
fn fetch(
&self,
_repository: &GitRepository,
_configured: &GitRemote,
_credential: &GitCredential,
) -> Result<bool, GitError> {
Err(self.0.clone())
}
}
struct AuthenticationFailure;
impl GitSmartHttpTransport for AuthenticationFailure {
@@ -371,13 +391,95 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let error = git
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)
.expect_err("conflicting histories");
let GitError::MergeConflicts { conflicts } = error else {
panic!("expected typed merge conflicts");
};
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].path(), Path::new("secret.gpg"));
assert_eq!(
error,
GitError::MergeConflicts {
paths: vec!["secret.gpg".into()]
}
conflicts[0].kind(),
ironstorage::git::GitConflictKind::Content
);
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"local");
let snapshot = git.snapshot(Some(remote), 5)?;
assert_eq!(snapshot.remote().expect("remote status").ahead(), 1);
assert_eq!(snapshot.remote().expect("remote status").behind(), 1);
assert_eq!(
git.resolve_fetched(
remote,
Some("main"),
&[GitConflictResolution::new(
"secret.gpg".into(),
GitConflictChoice::Remote,
)],
)?,
PullOutcome::Merged
);
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"remote");
assert_eq!(git.log(Some(1))?[0].parents().len(), 2);
Ok(())
}
#[test]
fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?;
let control = GitOperationControl::default();
control.cancel();
assert_eq!(
git.pull_with_transport_controlled(
remote,
Some("main"),
&Credentials,
&NoopFetch,
&control,
),
Err(GitError::Cancelled)
);
assert!(git.status()?.is_clean());
let phases = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&phases);
let progress = GitOperationControl::new(move |phase| {
recorded.lock().expect("progress lock").push(phase);
});
assert!(matches!(
git.pull_with_transport_controlled(
remote,
Some("main"),
&Credentials,
&NoopFetch,
&progress,
),
Err(GitError::RemoteNotFound { .. })
));
assert_eq!(
*phases.lock().expect("progress lock"),
[
GitProgressPhase::Validating,
GitProgressPhase::Authenticating,
GitProgressPhase::Receiving,
GitProgressPhase::Receiving,
GitProgressPhase::Integrating,
]
);
for expected in [GitError::NetworkUnavailable, GitError::TlsFailed] {
assert_eq!(
git.pull_with_transport(
remote,
Some("main"),
&Credentials,
&FailedFetch(expected.clone()),
),
Err(expected)
);
}
let credential = GitCredential::new("alice", b"DO-NOT-RENDER".to_vec())?;
assert_eq!(format!("{credential:?}"), "GitCredential([REDACTED])");
Ok(())
}