Implement embedded Git synchronization TUI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user