Add desktop HTTPS Git synchronization

This commit is contained in:
2026-08-10 19:53:00 +02:00
parent 8879df142d
commit 95bb10b4a1
6 changed files with 897 additions and 3 deletions

View File

@@ -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()?;

View File

@@ -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.

View File

@@ -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()?;