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

@@ -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<String> {
}
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

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

View File

@@ -145,6 +145,10 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::CopyEntry
| UiAction::DeleteEntry
| UiAction::ToggleReveal
| UiAction::GitStatus
| UiAction::GitPull
| UiAction::GitPush
| UiAction::GitSync
| UiAction::Minimize => return None,
};
Some(Accelerator::new(Some(modifiers), code))

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