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

@@ -33,12 +33,19 @@ use ironstorage::{
},
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
crypto::KeyInfo,
desktop::{DesktopError, DesktopErrorKind, DesktopMutationRequest, DesktopStorage},
desktop::{
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
DesktopMutationRequest, DesktopStorage,
},
document::{
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId,
EntryFieldKind, EntrySensitivity,
},
generate::GeneratorConfig,
git::{
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
GitProgressPhase, GitSnapshot,
},
mutation::{MutationAction, MutationOutcome, MutationSelection},
presentation::{ClipboardWait, NativeClipboardManager},
read::{FindResults, GrepResults, TreeModel, TreeNodeId},
@@ -59,6 +66,8 @@ type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, Deskto
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
type CreateCompletion = Arc<Mutex<Option<(SecretBytes, Result<EntryDocument, String>)>>>;
type SearchCompletion = Arc<Mutex<Option<Result<SearchResults, String>>>>;
type GitCompletion = Arc<Mutex<Option<Result<DesktopGitResult, DesktopError>>>>;
type GitProgress = Arc<Mutex<Option<GitProgressPhase>>>;
#[derive(Clone, Debug, Eq, PartialEq)]
struct RecipientSummary {
@@ -139,6 +148,14 @@ enum Message {
generation: u64,
result: Box<Result<MutationOutcome, String>>,
},
RunGit(DesktopGitRequest),
ChooseGitConflict(usize, GitConflictChoice),
ResolveGitConflicts,
CancelGit,
GitFinished {
generation: u64,
completion: GitCompletion,
},
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
@@ -244,6 +261,8 @@ struct App {
key: Option<KeyInfo>,
handle: Option<NativeAuthenticationHandle>,
sensitive: SensitiveUiState,
git_control: Option<GitOperationControl>,
git_progress: Option<GitProgress>,
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
@@ -299,9 +318,46 @@ enum UtilityView {
NewEntry(NewEntryForm),
Search(SearchForm),
Mutation(MutationForm),
Git(GitForm),
Help,
}
#[derive(Clone, Debug)]
struct GitConflictSelection {
conflict: GitConflict,
choice: Option<GitConflictChoice>,
}
#[derive(Clone, Debug, Default)]
struct GitForm {
snapshot: Option<GitSnapshot>,
progress: Option<GitProgressPhase>,
running: bool,
error: Option<String>,
conflicts: Vec<GitConflictSelection>,
}
impl GitForm {
fn resolutions(&self) -> Result<Vec<GitConflictResolution>, String> {
self.conflicts
.iter()
.map(|selection| {
selection
.choice
.map(|choice| {
GitConflictResolution::new(selection.conflict.path().to_owned(), choice)
})
.ok_or_else(|| {
format!(
"Choose the local or remote version for {}.",
selection.conflict.path().display()
)
})
})
.collect()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SearchMode {
Names,
@@ -667,6 +723,7 @@ enum PendingAction {
CreateEntry(NewEntryForm),
SearchContents(GrepRequest),
Mutate(MutationForm),
Git(DesktopGitRequest),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -704,6 +761,8 @@ impl App {
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
git_control: None,
git_progress: None,
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
@@ -797,6 +856,7 @@ impl App {
|| matches!(utility, UtilityView::NewEntry(form) if form.running)
|| matches!(utility, UtilityView::Search(form) if form.running)
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|| matches!(utility, UtilityView::Git(form) if form.running)
}) {
self.status = "Wait for the active workflow to finish…".to_owned();
} else {
@@ -1356,6 +1416,110 @@ impl App {
}
}
}
Message::RunGit(request) => {
if !matches!(&self.utility, Some(UtilityView::Git(form)) if !form.running) {
return Task::none();
}
let pending = PendingAction::Git(request.clone());
return if request.changes_worktree() {
self.request_action(pending)
} else {
self.execute_action(pending)
};
}
Message::ChooseGitConflict(index, choice) => {
if let Some(UtilityView::Git(form)) = &mut self.utility
&& !form.running
&& let Some(selection) = form.conflicts.get_mut(index)
{
selection.choice = Some(choice);
form.error = None;
}
}
Message::ResolveGitConflicts => {
let resolutions = match &self.utility {
Some(UtilityView::Git(form)) if !form.running => form.resolutions(),
_ => return Task::none(),
};
match resolutions {
Ok(resolutions) => {
return self.request_action(PendingAction::Git(
DesktopGitRequest::Resolve(resolutions),
));
}
Err(error) => {
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.error = Some(error.clone());
}
self.status = error;
}
}
}
Message::CancelGit => {
if let Some(control) = &self.git_control {
control.cancel();
self.status = "Cancelling Git operation…".to_owned();
}
}
Message::GitFinished {
generation,
completion,
} => {
let Some(result) = take_completion(&completion) else {
return Task::none();
};
if generation != self.workflow_generation {
return Task::none();
}
self.git_control = None;
self.git_progress = None;
match result {
Ok(result) => {
let (outcome, snapshot, tree) = result.into_parts();
if git_outcome_changes_worktree(&outcome) {
self.editor = None;
self.content_mode = ContentMode::Viewer;
self.conflict = false;
}
if let Some(tree) = tree {
self.navigation.replace(&tree);
self.tree_state =
tree_state_from_result(Ok(self.navigation.is_empty()));
}
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.snapshot = Some(snapshot);
form.progress = None;
form.running = false;
form.error = None;
form.conflicts.clear();
}
self.status = git_outcome_message(&outcome);
}
Err(error) => {
let conflicts = error
.conflicts()
.iter()
.cloned()
.map(|conflict| GitConflictSelection {
conflict,
choice: None,
})
.collect::<Vec<_>>();
let message = git_failure_message(&error);
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.progress = None;
form.running = false;
form.error = Some(message.clone());
form.conflicts = conflicts;
}
self.status = if error.kind() == DesktopErrorKind::Conflict {
format!("Git requires explicit conflict resolution: {message}")
} else {
format!("Git operation failed: {message}")
};
}
}
}
#[cfg(target_os = "macos")]
Message::PollNativeMenu => {
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
@@ -1707,6 +1871,7 @@ impl App {
Message::KeepConflictDraft => self.conflict = false,
Message::UserActivity => self.touch_user_activity(),
Message::Tick => {
self.poll_git_progress();
if let Some(session) = &self.session {
match poll_lease(session, &mut self.handle, &mut self.sensitive) {
Ok(LeasePoll::Active(remaining)) => {
@@ -1762,6 +1927,7 @@ impl App {
}),
entry_path: !self.entry_path.trim().is_empty(),
selected_object: self.navigation.selected().is_some(),
git_running: self.git_control.is_some(),
}
}
@@ -1873,6 +2039,22 @@ impl App {
return self.update(Message::ToggleReveal(id));
}
}
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
let request = match action {
UiAction::GitStatus => DesktopGitRequest::Refresh,
UiAction::GitPull => DesktopGitRequest::Pull,
UiAction::GitPush => DesktopGitRequest::Push,
UiAction::GitSync => DesktopGitRequest::Sync,
_ => unreachable!(),
};
self.utility = Some(UtilityView::Git(GitForm::default()));
let pending = PendingAction::Git(request.clone());
return if request.changes_worktree() {
self.request_action(pending)
} else {
self.execute_action(pending)
};
}
UiAction::Lock => return self.update(Message::Lock),
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {}
}
@@ -1971,6 +2153,10 @@ impl App {
}
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
if self.git_control.is_some() {
self.status = "Cancel or wait for the active Git operation first.".to_owned();
return Task::none();
}
if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) {
self.status =
"Wait for vault validation to finish before closing IronStorage.".to_owned();
@@ -2014,6 +2200,13 @@ impl App {
self.begin_authentication()
}
PendingAction::Mutate(form) => self.begin_mutation(form),
PendingAction::Git(request)
if request.requires_authentication() && self.handle.is_none() =>
{
self.after_authentication = Some(PendingAction::Git(request));
self.begin_authentication()
}
PendingAction::Git(request) => self.begin_git(request),
}
}
@@ -2110,6 +2303,47 @@ impl App {
)
}
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
};
let handle = self.handle.clone();
if request.requires_authentication() && handle.is_none() {
self.after_authentication = Some(PendingAction::Git(request));
return self.begin_authentication();
}
self.workflow_generation = self.workflow_generation.wrapping_add(1);
let generation = self.workflow_generation;
let progress = Arc::new(Mutex::new(None));
let reported = Arc::clone(&progress);
let control = GitOperationControl::new(move |phase| {
if let Ok(mut current) = reported.lock() {
*current = Some(phase);
}
});
self.git_control = Some(control.clone());
self.git_progress = Some(progress);
if let Some(UtilityView::Git(form)) = &mut self.utility {
form.progress = Some(GitProgressPhase::Validating);
form.running = true;
form.error = None;
}
self.status = format!("Git {}", git_request_name(&request));
Task::perform(
async move {
Arc::new(Mutex::new(Some(storage.git_operation(
handle.as_ref(),
&request,
&control,
))))
},
move |completion| Message::GitFinished {
generation,
completion,
},
)
}
fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task<Message> {
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
return Task::none();
@@ -2409,7 +2643,27 @@ impl App {
}
}
fn poll_git_progress(&mut self) {
let phase = self
.git_progress
.as_ref()
.and_then(|progress| progress.lock().ok().and_then(|phase| *phase));
let Some(phase) = phase else {
return;
};
if let Some(UtilityView::Git(form)) = &mut self.utility
&& form.progress != Some(phase)
{
form.progress = Some(phase);
self.status = format!("Git {}… (Cancel remains available)", git_phase_name(phase));
}
}
fn authentication_lost(&mut self, reason: String) {
if let Some(control) = self.git_control.take() {
control.cancel();
}
self.git_progress = None;
self.operation_generation = self.operation_generation.wrapping_add(1);
self.workflow_generation = self.workflow_generation.wrapping_add(1);
self.handle = None;
@@ -2440,6 +2694,11 @@ impl App {
form.running = false;
form.error = Some(reason.clone());
}
Some(UtilityView::Git(form)) => {
form.progress = None;
form.running = false;
form.error = Some(reason.clone());
}
_ => {}
}
self.authentication = AuthenticationView::Locked;
@@ -3002,6 +3261,113 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
content = content.push(text(format!("Mutation error: {error}")));
}
}
UtilityView::Git(form) => {
content = content
.push(text("Git Synchronization").size(28))
.push(text(
"All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.",
));
if let Some(phase) = form.progress {
content = content.push(text(format!("Progress: {}", git_phase_name(phase))));
}
if let Some(error) = &form.error {
content = content.push(text(format!("Git error: {error}")));
}
if let Some(snapshot) = &form.snapshot {
content = content
.push(text(format!("Repository: {}", snapshot.root().display())))
.push(text(format!("Branch: {}", snapshot.branch())));
if let Some(remote) = snapshot.remote() {
content = content.push(text(format!(
"HTTPS remote: {} · {} · {} ahead / {} behind",
remote.name(),
remote.url(),
remote.ahead(),
remote.behind()
)));
} else {
content = content.push(text("No HTTPS remote is configured."));
}
let status = snapshot.status();
content = content.push(text(if status.is_clean() {
"Worktree: clean".to_owned()
} else {
format!(
"Worktree: {} staged / {} unstaged change(s)",
status.staged().len(),
status.unstaged().len()
)
}));
for change in status.staged() {
content = content.push(text(format!(
"Staged · {:?} · {}",
change.kind(),
change.path().display()
)));
}
for change in status.unstaged() {
content = content.push(text(format!(
"Unstaged · {:?} · {}",
change.kind(),
change.path().display()
)));
}
content = content.push(text("Recent history").size(20));
if snapshot.recent().is_empty() {
content = content.push(text("No commits yet."));
}
for commit in snapshot.recent() {
content = content.push(text(format!(
"{} · {} · {}",
&commit.id()[..commit.id().len().min(12)],
commit.author_name(),
commit.message()
)));
}
}
if !form.conflicts.is_empty() {
content = content.push(text("Merge conflicts").size(20)).push(text(
"Choose exactly one complete version for every path. Resolution is committed by crates/storage only after all choices are present.",
));
for (index, selection) in form.conflicts.iter().enumerate() {
let local = button(text(
if selection.choice == Some(GitConflictChoice::Local) {
"Local ✓"
} else {
"Use local"
},
));
let remote = button(text(
if selection.choice == Some(GitConflictChoice::Remote) {
"Remote ✓"
} else {
"Use remote"
},
));
content = content.push(
column![
text(format!(
"{:?} · {}",
selection.conflict.kind(),
selection.conflict.path().display()
)),
row![
local.on_press(Message::ChooseGitConflict(
index,
GitConflictChoice::Local
)),
remote.on_press(Message::ChooseGitConflict(
index,
GitConflictChoice::Remote
)),
]
.spacing(8),
]
.spacing(4),
);
}
}
}
UtilityView::Help => {
content = content
.push(text("IronStorage Help").size(28))
@@ -3041,7 +3407,10 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
let done = button("Done (Esc)");
let busy = matches!(utility, UtilityView::Settings(form) if form.saving)
|| matches!(utility, UtilityView::Recipients(form) if form.running)
|| matches!(utility, UtilityView::NewEntry(form) if form.running);
|| matches!(utility, UtilityView::NewEntry(form) if form.running)
|| matches!(utility, UtilityView::Search(form) if form.running)
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|| matches!(utility, UtilityView::Git(form) if form.running);
let done = if busy {
done
} else {
@@ -3132,6 +3501,29 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
]
.spacing(8)
}
UtilityView::Git(form) => {
if form.running {
row![
button("Cancel Git operation").on_press(Message::CancelGit),
done
]
.spacing(8)
} else {
let mut actions = row![
button("Refresh").on_press(Message::RunGit(DesktopGitRequest::Refresh)),
button("Pull").on_press(Message::RunGit(DesktopGitRequest::Pull)),
button("Push").on_press(Message::RunGit(DesktopGitRequest::Push)),
button("Synchronize").on_press(Message::RunGit(DesktopGitRequest::Sync)),
]
.spacing(8);
if !form.conflicts.is_empty() {
actions = actions.push(
button("Resolve selected versions").on_press(Message::ResolveGitConflicts),
);
}
actions.push(done)
}
}
UtilityView::About | UtilityView::Help => row![done],
};
container(
@@ -3566,6 +3958,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
},
form.source.path().display()
),
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
};
container(
column![
@@ -3586,6 +3979,107 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
.into()
}
fn git_request_name(request: &DesktopGitRequest) -> &'static str {
match request {
DesktopGitRequest::Refresh => "status refresh",
DesktopGitRequest::Pull => "pull",
DesktopGitRequest::Push => "push",
DesktopGitRequest::Sync => "synchronization",
DesktopGitRequest::Resolve(_) => "conflict resolution",
}
}
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating the repository and HTTPS remote",
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
GitProgressPhase::Refreshing => "refreshing repository state",
}
}
fn git_outcome_message(outcome: &DesktopGitOutcome) -> String {
match outcome {
DesktopGitOutcome::Refreshed => "Git status and history refreshed.".to_owned(),
DesktopGitOutcome::Pulled(outcome) => format!("Git pull completed: {outcome:?}."),
DesktopGitOutcome::Pushed(outcome) => format!(
"Pushed {} branch {} at {}.",
outcome.remote(),
outcome.branch(),
&outcome.new_id()[..outcome.new_id().len().min(12)]
),
DesktopGitOutcome::Synchronized { pull, push } => format!(
"Git synchronization completed: {pull:?}; pushed {} at {}.",
push.remote(),
&push.new_id()[..push.new_id().len().min(12)]
),
DesktopGitOutcome::Resolved(outcome) => {
format!("Git conflicts resolved and committed: {outcome:?}.")
}
}
}
fn git_outcome_changes_worktree(outcome: &DesktopGitOutcome) -> bool {
match outcome {
DesktopGitOutcome::Pulled(outcome) => {
!matches!(outcome, ironstorage::git::PullOutcome::UpToDate)
}
DesktopGitOutcome::Synchronized { pull, .. } => {
!matches!(pull, ironstorage::git::PullOutcome::UpToDate)
}
DesktopGitOutcome::Resolved(_) => true,
DesktopGitOutcome::Refreshed | DesktopGitOutcome::Pushed(_) => false,
}
}
fn git_failure_message(error: &DesktopError) -> String {
match error.git_error() {
Some(GitError::NotRepository) => {
"No embedded Git repository exists for this password store.".to_owned()
}
Some(GitError::ForbiddenRemoteUrl) => {
"The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
}
Some(GitError::RemoteNotFound { name }) => {
format!("The configured HTTPS remote {name} is missing from the repository.")
}
Some(GitError::CredentialsUnavailable) => {
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned()
}
Some(GitError::CredentialAccessDenied) => {
"Access to the HTTPS credential was denied; retry and approve the secure-storage request.".to_owned()
}
Some(GitError::CredentialCancelled) => {
"The HTTPS credential request was cancelled; retry when ready.".to_owned()
}
Some(GitError::AuthenticationFailed) => {
"The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
}
Some(GitError::NetworkUnavailable) => {
"The HTTPS Git server is unreachable; check the network and retry.".to_owned()
}
Some(GitError::TlsFailed) => {
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
}
Some(GitError::Cancelled) => "The Git operation was cancelled safely.".to_owned(),
Some(GitError::NonFastForward) => {
"The push is not a fast-forward; pull and resolve remote changes before retrying."
.to_owned()
}
Some(GitError::DirtyWorktree) => {
"The repository has uncommitted changes; commit or restore them before synchronization."
.to_owned()
}
Some(GitError::InvalidConflictResolution { path }) => format!(
"The conflict choice for {} is no longer valid; refresh conflicts and choose again.",
path.display()
),
_ => error.to_string(),
}
}
fn generation_view(form: &GenerateForm) -> Element<'_, Message> {
let mut content = column![
text(if form.replacement {
@@ -3936,6 +4430,8 @@ mod tests {
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
git_control: None,
git_progress: None,
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
@@ -4128,6 +4624,9 @@ mod tests {
PendingAction::OpenEntry("other".to_owned()),
PendingAction::Reload("draft".to_owned()),
PendingAction::CloseWindow(window::Id::unique()),
PendingAction::Git(DesktopGitRequest::Pull),
PendingAction::Git(DesktopGitRequest::Sync),
PendingAction::Git(DesktopGitRequest::Resolve(Vec::new())),
] {
assert!(matches!(
action,
@@ -4135,6 +4634,7 @@ mod tests {
| PendingAction::OpenEntry(_)
| PendingAction::Reload(_)
| PendingAction::CloseWindow(_)
| PendingAction::Git(_)
));
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
}
@@ -4154,6 +4654,14 @@ mod tests {
let _task = app.update(Message::CancelDiscard);
assert!(app.confirmation.is_none());
let _task = app.request_action(PendingAction::Git(DesktopGitRequest::Pull));
assert_eq!(
app.confirmation,
Some(PendingAction::Git(DesktopGitRequest::Pull))
);
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
let _task = app.update(Message::CancelDiscard);
let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
app.navigation.replace_test_nodes(vec![
@@ -4828,6 +5336,82 @@ mod tests {
);
}
#[test]
fn desktop_git_status_is_typed_https_only_and_cancellation_safe() {
let (temporary, _initial) = fixture_storage();
let config_path = temporary.path().join("config.toml");
let mut config = fs::read_to_string(&config_path).expect("configuration");
config.push_str(
"[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/store.git\"\nserver_id = \"server\"\napplication_id = \"application\"\n",
);
fs::write(&config_path, config).expect("configured remote");
let storage = DesktopStorage::load(Some(&config_path)).expect("reload remote");
let repository = Repository::open(storage.vault()).expect("repository");
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
.expect("initialize embedded Git");
git.add_remote("origin", "https://example.test/store.git")
.expect("HTTPS remote");
let phases = Arc::new(Mutex::new(Vec::new()));
let reported = Arc::clone(&phases);
let control = GitOperationControl::new(move |phase| {
reported.lock().expect("progress").push(phase);
});
let result = storage
.git_operation(None, &DesktopGitRequest::Refresh, &control)
.expect("status refresh");
assert_eq!(result.outcome(), &DesktopGitOutcome::Refreshed);
assert_eq!(result.snapshot().branch(), "main");
assert!(result.snapshot().status().is_clean());
assert_eq!(
result.snapshot().remote().expect("remote").url(),
"https://example.test/store.git"
);
assert_eq!(
*phases.lock().expect("progress"),
[GitProgressPhase::Validating]
);
let cancelled = GitOperationControl::default();
cancelled.cancel();
let error = storage
.git_operation(None, &DesktopGitRequest::Refresh, &cancelled)
.expect_err("cancel before repository access");
assert_eq!(error.kind(), DesktopErrorKind::Git);
assert_eq!(error.to_string(), "the Git operation was cancelled");
assert_eq!(error.git_error(), Some(&GitError::Cancelled));
assert_eq!(
git_failure_message(&error),
"The Git operation was cancelled safely."
);
let git_config = storage.vault().join(".git/config");
let invalid = fs::read_to_string(&git_config)
.expect("Git config")
.replace(
"https://example.test/store.git",
"ssh://example.test/store.git",
);
fs::write(git_config, invalid).expect("hostile remote fixture");
let error = storage
.git_operation(
None,
&DesktopGitRequest::Refresh,
&GitOperationControl::default(),
)
.expect_err("non-HTTPS remote rejection");
assert_eq!(error.kind(), DesktopErrorKind::Git);
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
assert_eq!(
error.to_string(),
"Git remotes must use credential-free HTTPS URLs"
);
assert!(DesktopGitRequest::Pull.requires_authentication());
assert!(DesktopGitRequest::Pull.changes_worktree());
assert!(!DesktopGitRequest::Push.changes_worktree());
}
#[test]
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
let (_temporary, storage) = fixture_storage();