Add desktop HTTPS Git synchronization
This commit is contained in:
@@ -10,7 +10,12 @@ use crate::{
|
||||
config::{Config, ConfigSettings, EditorCommand},
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService},
|
||||
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity},
|
||||
git::{
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter,
|
||||
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome, ReqwestGitTransport,
|
||||
},
|
||||
mutation::{MutationOutcome, TreeMutator},
|
||||
presentation::ClipboardTimeout,
|
||||
read::{FindResults, GrepResults, TreeModel, VaultReader},
|
||||
@@ -43,6 +48,8 @@ pub enum DesktopErrorKind {
|
||||
pub struct DesktopError {
|
||||
kind: DesktopErrorKind,
|
||||
message: String,
|
||||
conflicts: Vec<GitConflict>,
|
||||
git: Option<GitError>,
|
||||
}
|
||||
|
||||
impl DesktopError {
|
||||
@@ -50,10 +57,35 @@ impl DesktopError {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn conflicts(&self) -> &[GitConflict] {
|
||||
&self.conflicts
|
||||
}
|
||||
|
||||
pub fn git_error(&self) -> Option<&GitError> {
|
||||
self.git.as_ref()
|
||||
}
|
||||
|
||||
fn new(kind: DesktopErrorKind, error: impl fmt::Display) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: error.to_string(),
|
||||
conflicts: Vec::new(),
|
||||
git: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn git(error: GitError) -> Self {
|
||||
let (kind, conflicts) = match &error {
|
||||
GitError::MergeConflicts { conflicts } => {
|
||||
(DesktopErrorKind::Conflict, conflicts.clone())
|
||||
}
|
||||
_ => (DesktopErrorKind::Git, Vec::new()),
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
message: error.to_string(),
|
||||
conflicts,
|
||||
git: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +121,58 @@ pub enum DesktopMutationRequest {
|
||||
Copy(CopyRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DesktopGitRequest {
|
||||
Refresh,
|
||||
Pull,
|
||||
Push,
|
||||
Sync,
|
||||
Resolve(Vec<GitConflictResolution>),
|
||||
}
|
||||
|
||||
impl DesktopGitRequest {
|
||||
pub fn requires_authentication(&self) -> bool {
|
||||
!matches!(self, Self::Refresh)
|
||||
}
|
||||
|
||||
pub fn changes_worktree(&self) -> bool {
|
||||
matches!(self, Self::Pull | Self::Sync | Self::Resolve(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DesktopGitOutcome {
|
||||
Refreshed,
|
||||
Pulled(PullOutcome),
|
||||
Pushed(PushOutcome),
|
||||
Synchronized {
|
||||
pull: PullOutcome,
|
||||
push: PushOutcome,
|
||||
},
|
||||
Resolved(PullOutcome),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DesktopGitResult {
|
||||
outcome: DesktopGitOutcome,
|
||||
snapshot: GitSnapshot,
|
||||
tree: Option<TreeModel>,
|
||||
}
|
||||
|
||||
impl DesktopGitResult {
|
||||
pub fn outcome(&self) -> &DesktopGitOutcome {
|
||||
&self.outcome
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &GitSnapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
pub fn into_parts(self) -> (DesktopGitOutcome, GitSnapshot, Option<TreeModel>) {
|
||||
(self.outcome, self.snapshot, self.tree)
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopMutationRequest {
|
||||
pub fn source(&self) -> &str {
|
||||
match self {
|
||||
@@ -218,6 +302,92 @@ impl DesktopStorage {
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||
}
|
||||
|
||||
pub fn git_operation(
|
||||
&self,
|
||||
handle: Option<&NativeAuthenticationHandle>,
|
||||
request: &DesktopGitRequest,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<DesktopGitResult, DesktopError> {
|
||||
control
|
||||
.report(GitProgressPhase::Validating)
|
||||
.map_err(DesktopError::git)?;
|
||||
let repository = self.repository()?;
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||
.map_err(DesktopError::git)?;
|
||||
let configured = || {
|
||||
self.config.git_remote(None).ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Configuration,
|
||||
"no HTTPS Git remote is configured",
|
||||
)
|
||||
})
|
||||
};
|
||||
let authenticated = || {
|
||||
let handle = handle.ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Authentication,
|
||||
"authentication is required for HTTPS Git credentials",
|
||||
)
|
||||
})?;
|
||||
handle
|
||||
.ensure_active()
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
|
||||
Ok(handle)
|
||||
};
|
||||
let (outcome, changed_tree) = match request {
|
||||
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
|
||||
DesktopGitRequest::Pull => {
|
||||
let outcome = git
|
||||
.pull_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pulled(outcome), true)
|
||||
}
|
||||
DesktopGitRequest::Push => {
|
||||
let outcome = git
|
||||
.push_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pushed(outcome), false)
|
||||
}
|
||||
DesktopGitRequest::Sync => {
|
||||
let (pull, push) = git
|
||||
.sync_controlled(configured()?, authenticated()?, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Synchronized { pull, push }, true)
|
||||
}
|
||||
DesktopGitRequest::Resolve(resolutions) => {
|
||||
let _handle = authenticated()?;
|
||||
control
|
||||
.report(GitProgressPhase::Integrating)
|
||||
.map_err(DesktopError::git)?;
|
||||
let outcome = git
|
||||
.resolve_fetched(configured()?, None, resolutions)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Resolved(outcome), true)
|
||||
}
|
||||
};
|
||||
let snapshot = git
|
||||
.snapshot(self.config.git_remote(None), 10)
|
||||
.map_err(DesktopError::git)?;
|
||||
let tree = changed_tree.then(|| self.tree()).transpose()?;
|
||||
Ok(DesktopGitResult {
|
||||
outcome,
|
||||
snapshot,
|
||||
tree,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||
let repository = self.repository()?;
|
||||
let keys = self.keys()?;
|
||||
|
||||
@@ -1486,6 +1486,36 @@ impl GitRepository {
|
||||
Ok((pull, push))
|
||||
}
|
||||
|
||||
pub fn sync_controlled(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
||||
self.sync_with_transports_controlled(
|
||||
configured,
|
||||
credentials,
|
||||
&EmbeddedFetchTransport,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sync_with_transports_controlled(
|
||||
&self,
|
||||
configured: &GitRemote,
|
||||
credentials: &impl GitCredentialProvider,
|
||||
fetch: &impl GitFetchTransport,
|
||||
push: &impl GitSmartHttpTransport,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
||||
let pull =
|
||||
self.pull_with_transport_controlled(configured, None, credentials, fetch, control)?;
|
||||
let push =
|
||||
self.push_with_transport_controlled(configured, None, credentials, push, control)?;
|
||||
Ok((pull, push))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -341,6 +341,34 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> 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 head = git.log(Some(1))?[0].id().to_owned();
|
||||
set_remote_tracking(config.vault(), &head)?;
|
||||
let phases = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded = Arc::clone(&phases);
|
||||
let control = GitOperationControl::new(move |phase| {
|
||||
recorded.lock().expect("progress").push(phase);
|
||||
});
|
||||
let push = RecordingTransport::default();
|
||||
let (pull, pushed) =
|
||||
git.sync_with_transports_controlled(remote, &Credentials, &NoopFetch, &push, &control)?;
|
||||
assert_eq!(pull, PullOutcome::UpToDate);
|
||||
assert_eq!(pushed.new_id(), head);
|
||||
let phases = phases.lock().expect("progress");
|
||||
assert!(phases.contains(&GitProgressPhase::Receiving));
|
||||
assert!(phases.contains(&GitProgressPhase::Integrating));
|
||||
assert!(phases.contains(&GitProgressPhase::Sending));
|
||||
assert!(!push.request.lock().expect("push request").is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
|
||||
Reference in New Issue
Block a user