diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index c4e589f..118035a 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -32,6 +32,10 @@ pub enum UiAction { CopyEntry, DeleteEntry, ToggleReveal, + GitStatus, + GitPull, + GitPush, + GitSync, Lock, Minimize, Help, @@ -68,6 +72,10 @@ impl UiAction { Self::CopyEntry => "copy-entry", Self::DeleteEntry => "delete-entry", Self::ToggleReveal => "toggle-reveal", + Self::GitStatus => "git-status", + Self::GitPull => "git-pull", + Self::GitPush => "git-push", + Self::GitSync => "git-sync", Self::Lock => "lock", Self::Minimize => "minimize", Self::Help => "help", @@ -129,6 +137,7 @@ pub struct ActionContext { pub focused_generatable: bool, pub entry_path: bool, pub selected_object: bool, + pub git_running: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -228,6 +237,10 @@ pub const ACTIONS: &[ActionSpec] = &[ "Reveal or Hide Field", None, ), + spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None), + spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None), + spec(UiAction::GitPush, MenuGroup::Tools, "Push", None), + spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None), spec(UiAction::Lock, MenuGroup::Tools, "Lock", Some("⌘L")), spec( UiAction::Minimize, @@ -278,6 +291,9 @@ pub fn shortcut_label(action: UiAction) -> Option { } pub fn enabled(action: UiAction, context: ActionContext) -> bool { + if context.git_running && !matches!(action, UiAction::Lock | UiAction::Minimize) { + return false; + } match action { UiAction::About | UiAction::Help => !context.modal_open, UiAction::Settings => { @@ -362,6 +378,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.switching_vault && context.focused_sensitive } + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => { + context.storage_ready + && !context.saving + && !context.switching_vault + && !context.git_running + && !context.modal_open + } UiAction::Lock => context.unlocked, } } @@ -370,6 +393,9 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta if enabled(action, context) { return None; } + if context.git_running && !matches!(action, UiAction::Lock | UiAction::Minimize) { + return Some("Cancel or wait for the active Git operation"); + } Some(match action { UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if !context.storage_ready => @@ -471,6 +497,29 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta UiAction::ToggleReveal if !context.document_open => "Open an entry first", UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first", UiAction::ToggleReveal => "Wait for vault validation", + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync + if !context.storage_ready => + { + "Shared configuration is unavailable" + } + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync + if context.saving => + { + "Wait for the active save" + } + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync + if context.switching_vault => + { + "Wait for vault validation" + } + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync + if context.git_running => + { + "A Git operation is already running" + } + UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => { + "Close the current screen first" + } UiAction::Lock => "The password store is already locked", UiAction::CommandPalette => "Finish the current confirmation first", UiAction::Settings if !context.storage_ready => "Shared configuration is unavailable", @@ -512,6 +561,10 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"], UiAction::DeleteEntry => &["remove", "rm", "delete folder"], UiAction::ToggleReveal => &["show password", "hide password", "reveal field"], + UiAction::GitStatus => &["repository status", "git history"], + UiAction::GitPull => &["fetch", "download changes"], + UiAction::GitPush => &["upload changes"], + UiAction::GitSync => &["synchronize", "pull and push"], UiAction::Lock => &["secure", "log out", "relock"], UiAction::Minimize => &["hide window"], UiAction::Help => &["shortcuts", "documentation"], @@ -572,6 +625,7 @@ mod tests { focused_generatable: true, entry_path: true, selected_object: true, + git_running: false, } } @@ -599,6 +653,14 @@ mod tests { assert!(enabled(UiAction::GeneratePassword, ready)); assert!(!enabled(UiAction::CopyField, ready)); assert!(enabled(UiAction::ToggleReveal, ready)); + for action in [ + UiAction::GitStatus, + UiAction::GitPull, + UiAction::GitPush, + UiAction::GitSync, + ] { + assert!(enabled(action, ready)); + } let viewing = ActionContext { editing: false, @@ -631,6 +693,9 @@ mod tests { UiAction::MoveEntry, UiAction::CopyEntry, UiAction::DeleteEntry, + UiAction::GitPull, + UiAction::GitPush, + UiAction::GitSync, ] { assert!( enabled(action, locked), @@ -708,6 +773,19 @@ mod tests { ), Some("Finish the current confirmation first") ); + + let git_running = ActionContext { + git_running: true, + ..ready + }; + assert!(enabled(UiAction::Lock, git_running)); + assert!(enabled(UiAction::Minimize, git_running)); + assert!(!enabled(UiAction::GitStatus, git_running)); + assert!(!enabled(UiAction::CloseWindow, git_running)); + assert_eq!( + disabled_reason(UiAction::CloseWindow, git_running), + Some("Cancel or wait for the active Git operation") + ); let modal = ActionContext { modal_open: true, ..ready diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index f439314..d0a2c59 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -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>>>; type CreateCompletion = Arc)>>>; type SearchCompletion = Arc>>>; +type GitCompletion = Arc>>>; +type GitProgress = Arc>>; #[derive(Clone, Debug, Eq, PartialEq)] struct RecipientSummary { @@ -139,6 +148,14 @@ enum Message { generation: u64, result: Box>, }, + RunGit(DesktopGitRequest), + ChooseGitConflict(usize, GitConflictChoice), + ResolveGitConflicts, + CancelGit, + GitFinished { + generation: u64, + completion: GitCompletion, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -244,6 +261,8 @@ struct App { key: Option, handle: Option, sensitive: SensitiveUiState, + git_control: Option, + git_progress: Option, 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, +} + +#[derive(Clone, Debug, Default)] +struct GitForm { + snapshot: Option, + progress: Option, + running: bool, + error: Option, + conflicts: Vec, +} + +impl GitForm { + fn resolutions(&self) -> Result, 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::>(); + 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 { + 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 { + 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 { 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(); diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index df71190..59d4958 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -145,6 +145,10 @@ fn accelerator(action: UiAction) -> Option { | UiAction::CopyEntry | UiAction::DeleteEntry | UiAction::ToggleReveal + | UiAction::GitStatus + | UiAction::GitPull + | UiAction::GitPush + | UiAction::GitSync | UiAction::Minimize => return None, }; Some(Accelerator::new(Some(modifiers), code)) diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index 6772c35..1ade0ef 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -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, + git: Option, } 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), +} + +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, +} + +impl DesktopGitResult { + pub fn outcome(&self) -> &DesktopGitOutcome { + &self.outcome + } + + pub fn snapshot(&self) -> &GitSnapshot { + &self.snapshot + } + + pub fn into_parts(self) -> (DesktopGitOutcome, GitSnapshot, Option) { + (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 { + 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 { let repository = self.repository()?; let keys = self.keys()?; diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index bce0792..e045eee 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -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. diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 0102298..778c074 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -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()?;