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