Add desktop HTTPS Git synchronization
This commit is contained in:
@@ -32,6 +32,10 @@ pub enum UiAction {
|
|||||||
CopyEntry,
|
CopyEntry,
|
||||||
DeleteEntry,
|
DeleteEntry,
|
||||||
ToggleReveal,
|
ToggleReveal,
|
||||||
|
GitStatus,
|
||||||
|
GitPull,
|
||||||
|
GitPush,
|
||||||
|
GitSync,
|
||||||
Lock,
|
Lock,
|
||||||
Minimize,
|
Minimize,
|
||||||
Help,
|
Help,
|
||||||
@@ -68,6 +72,10 @@ impl UiAction {
|
|||||||
Self::CopyEntry => "copy-entry",
|
Self::CopyEntry => "copy-entry",
|
||||||
Self::DeleteEntry => "delete-entry",
|
Self::DeleteEntry => "delete-entry",
|
||||||
Self::ToggleReveal => "toggle-reveal",
|
Self::ToggleReveal => "toggle-reveal",
|
||||||
|
Self::GitStatus => "git-status",
|
||||||
|
Self::GitPull => "git-pull",
|
||||||
|
Self::GitPush => "git-push",
|
||||||
|
Self::GitSync => "git-sync",
|
||||||
Self::Lock => "lock",
|
Self::Lock => "lock",
|
||||||
Self::Minimize => "minimize",
|
Self::Minimize => "minimize",
|
||||||
Self::Help => "help",
|
Self::Help => "help",
|
||||||
@@ -129,6 +137,7 @@ pub struct ActionContext {
|
|||||||
pub focused_generatable: bool,
|
pub focused_generatable: bool,
|
||||||
pub entry_path: bool,
|
pub entry_path: bool,
|
||||||
pub selected_object: bool,
|
pub selected_object: bool,
|
||||||
|
pub git_running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -228,6 +237,10 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
"Reveal or Hide Field",
|
"Reveal or Hide Field",
|
||||||
None,
|
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::Lock, MenuGroup::Tools, "Lock", Some("⌘L")),
|
||||||
spec(
|
spec(
|
||||||
UiAction::Minimize,
|
UiAction::Minimize,
|
||||||
@@ -278,6 +291,9 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||||
|
if context.git_running && !matches!(action, UiAction::Lock | UiAction::Minimize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
match action {
|
match action {
|
||||||
UiAction::About | UiAction::Help => !context.modal_open,
|
UiAction::About | UiAction::Help => !context.modal_open,
|
||||||
UiAction::Settings => {
|
UiAction::Settings => {
|
||||||
@@ -362,6 +378,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|||||||
&& !context.switching_vault
|
&& !context.switching_vault
|
||||||
&& context.focused_sensitive
|
&& 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,
|
UiAction::Lock => context.unlocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +393,9 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
|||||||
if enabled(action, context) {
|
if enabled(action, context) {
|
||||||
return None;
|
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 {
|
Some(match action {
|
||||||
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry
|
UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry
|
||||||
if !context.storage_ready =>
|
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.document_open => "Open an entry first",
|
||||||
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
|
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
|
||||||
UiAction::ToggleReveal => "Wait for vault validation",
|
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::Lock => "The password store is already locked",
|
||||||
UiAction::CommandPalette => "Finish the current confirmation first",
|
UiAction::CommandPalette => "Finish the current confirmation first",
|
||||||
UiAction::Settings if !context.storage_ready => "Shared configuration is unavailable",
|
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::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
|
||||||
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
|
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
|
||||||
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
|
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::Lock => &["secure", "log out", "relock"],
|
||||||
UiAction::Minimize => &["hide window"],
|
UiAction::Minimize => &["hide window"],
|
||||||
UiAction::Help => &["shortcuts", "documentation"],
|
UiAction::Help => &["shortcuts", "documentation"],
|
||||||
@@ -572,6 +625,7 @@ mod tests {
|
|||||||
focused_generatable: true,
|
focused_generatable: true,
|
||||||
entry_path: true,
|
entry_path: true,
|
||||||
selected_object: true,
|
selected_object: true,
|
||||||
|
git_running: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,6 +653,14 @@ mod tests {
|
|||||||
assert!(enabled(UiAction::GeneratePassword, ready));
|
assert!(enabled(UiAction::GeneratePassword, ready));
|
||||||
assert!(!enabled(UiAction::CopyField, ready));
|
assert!(!enabled(UiAction::CopyField, ready));
|
||||||
assert!(enabled(UiAction::ToggleReveal, ready));
|
assert!(enabled(UiAction::ToggleReveal, ready));
|
||||||
|
for action in [
|
||||||
|
UiAction::GitStatus,
|
||||||
|
UiAction::GitPull,
|
||||||
|
UiAction::GitPush,
|
||||||
|
UiAction::GitSync,
|
||||||
|
] {
|
||||||
|
assert!(enabled(action, ready));
|
||||||
|
}
|
||||||
|
|
||||||
let viewing = ActionContext {
|
let viewing = ActionContext {
|
||||||
editing: false,
|
editing: false,
|
||||||
@@ -631,6 +693,9 @@ mod tests {
|
|||||||
UiAction::MoveEntry,
|
UiAction::MoveEntry,
|
||||||
UiAction::CopyEntry,
|
UiAction::CopyEntry,
|
||||||
UiAction::DeleteEntry,
|
UiAction::DeleteEntry,
|
||||||
|
UiAction::GitPull,
|
||||||
|
UiAction::GitPush,
|
||||||
|
UiAction::GitSync,
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
enabled(action, locked),
|
enabled(action, locked),
|
||||||
@@ -708,6 +773,19 @@ mod tests {
|
|||||||
),
|
),
|
||||||
Some("Finish the current confirmation first")
|
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 {
|
let modal = ActionContext {
|
||||||
modal_open: true,
|
modal_open: true,
|
||||||
..ready
|
..ready
|
||||||
|
|||||||
@@ -33,12 +33,19 @@ use ironstorage::{
|
|||||||
},
|
},
|
||||||
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
|
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
|
||||||
crypto::KeyInfo,
|
crypto::KeyInfo,
|
||||||
desktop::{DesktopError, DesktopErrorKind, DesktopMutationRequest, DesktopStorage},
|
desktop::{
|
||||||
|
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
|
||||||
|
DesktopMutationRequest, DesktopStorage,
|
||||||
|
},
|
||||||
document::{
|
document::{
|
||||||
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId,
|
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId,
|
||||||
EntryFieldKind, EntrySensitivity,
|
EntryFieldKind, EntrySensitivity,
|
||||||
},
|
},
|
||||||
generate::GeneratorConfig,
|
generate::GeneratorConfig,
|
||||||
|
git::{
|
||||||
|
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
||||||
|
GitProgressPhase, GitSnapshot,
|
||||||
|
},
|
||||||
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
||||||
presentation::{ClipboardWait, NativeClipboardManager},
|
presentation::{ClipboardWait, NativeClipboardManager},
|
||||||
read::{FindResults, GrepResults, TreeModel, TreeNodeId},
|
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 TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
|
||||||
type CreateCompletion = Arc<Mutex<Option<(SecretBytes, Result<EntryDocument, String>)>>>;
|
type CreateCompletion = Arc<Mutex<Option<(SecretBytes, Result<EntryDocument, String>)>>>;
|
||||||
type SearchCompletion = Arc<Mutex<Option<Result<SearchResults, 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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
struct RecipientSummary {
|
struct RecipientSummary {
|
||||||
@@ -139,6 +148,14 @@ enum Message {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
result: Box<Result<MutationOutcome, String>>,
|
result: Box<Result<MutationOutcome, String>>,
|
||||||
},
|
},
|
||||||
|
RunGit(DesktopGitRequest),
|
||||||
|
ChooseGitConflict(usize, GitConflictChoice),
|
||||||
|
ResolveGitConflicts,
|
||||||
|
CancelGit,
|
||||||
|
GitFinished {
|
||||||
|
generation: u64,
|
||||||
|
completion: GitCompletion,
|
||||||
|
},
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
PollNativeMenu,
|
PollNativeMenu,
|
||||||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||||||
@@ -244,6 +261,8 @@ struct App {
|
|||||||
key: Option<KeyInfo>,
|
key: Option<KeyInfo>,
|
||||||
handle: Option<NativeAuthenticationHandle>,
|
handle: Option<NativeAuthenticationHandle>,
|
||||||
sensitive: SensitiveUiState,
|
sensitive: SensitiveUiState,
|
||||||
|
git_control: Option<GitOperationControl>,
|
||||||
|
git_progress: Option<GitProgress>,
|
||||||
authentication_generation: u64,
|
authentication_generation: u64,
|
||||||
operation_generation: u64,
|
operation_generation: u64,
|
||||||
tree_generation: u64,
|
tree_generation: u64,
|
||||||
@@ -299,9 +318,46 @@ enum UtilityView {
|
|||||||
NewEntry(NewEntryForm),
|
NewEntry(NewEntryForm),
|
||||||
Search(SearchForm),
|
Search(SearchForm),
|
||||||
Mutation(MutationForm),
|
Mutation(MutationForm),
|
||||||
|
Git(GitForm),
|
||||||
Help,
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum SearchMode {
|
enum SearchMode {
|
||||||
Names,
|
Names,
|
||||||
@@ -667,6 +723,7 @@ enum PendingAction {
|
|||||||
CreateEntry(NewEntryForm),
|
CreateEntry(NewEntryForm),
|
||||||
SearchContents(GrepRequest),
|
SearchContents(GrepRequest),
|
||||||
Mutate(MutationForm),
|
Mutate(MutationForm),
|
||||||
|
Git(DesktopGitRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -704,6 +761,8 @@ impl App {
|
|||||||
key: None,
|
key: None,
|
||||||
handle: None,
|
handle: None,
|
||||||
sensitive: SensitiveUiState::default(),
|
sensitive: SensitiveUiState::default(),
|
||||||
|
git_control: None,
|
||||||
|
git_progress: None,
|
||||||
authentication_generation: 0,
|
authentication_generation: 0,
|
||||||
operation_generation: 0,
|
operation_generation: 0,
|
||||||
tree_generation: 0,
|
tree_generation: 0,
|
||||||
@@ -797,6 +856,7 @@ impl App {
|
|||||||
|| 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::Search(form) if form.running)
|
||||||
|| matches!(utility, UtilityView::Mutation(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();
|
self.status = "Wait for the active workflow to finish…".to_owned();
|
||||||
} else {
|
} 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")]
|
#[cfg(target_os = "macos")]
|
||||||
Message::PollNativeMenu => {
|
Message::PollNativeMenu => {
|
||||||
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
|
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::KeepConflictDraft => self.conflict = false,
|
||||||
Message::UserActivity => self.touch_user_activity(),
|
Message::UserActivity => self.touch_user_activity(),
|
||||||
Message::Tick => {
|
Message::Tick => {
|
||||||
|
self.poll_git_progress();
|
||||||
if let Some(session) = &self.session {
|
if let Some(session) = &self.session {
|
||||||
match poll_lease(session, &mut self.handle, &mut self.sensitive) {
|
match poll_lease(session, &mut self.handle, &mut self.sensitive) {
|
||||||
Ok(LeasePoll::Active(remaining)) => {
|
Ok(LeasePoll::Active(remaining)) => {
|
||||||
@@ -1762,6 +1927,7 @@ impl App {
|
|||||||
}),
|
}),
|
||||||
entry_path: !self.entry_path.trim().is_empty(),
|
entry_path: !self.entry_path.trim().is_empty(),
|
||||||
selected_object: self.navigation.selected().is_some(),
|
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));
|
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::Lock => return self.update(Message::Lock),
|
||||||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {}
|
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {}
|
||||||
}
|
}
|
||||||
@@ -1971,6 +2153,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
|
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(_)) {
|
if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) {
|
||||||
self.status =
|
self.status =
|
||||||
"Wait for vault validation to finish before closing IronStorage.".to_owned();
|
"Wait for vault validation to finish before closing IronStorage.".to_owned();
|
||||||
@@ -2014,6 +2200,13 @@ impl App {
|
|||||||
self.begin_authentication()
|
self.begin_authentication()
|
||||||
}
|
}
|
||||||
PendingAction::Mutate(form) => self.begin_mutation(form),
|
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> {
|
fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task<Message> {
|
||||||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||||||
return Task::none();
|
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) {
|
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.operation_generation = self.operation_generation.wrapping_add(1);
|
||||||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||||||
self.handle = None;
|
self.handle = None;
|
||||||
@@ -2440,6 +2694,11 @@ impl App {
|
|||||||
form.running = false;
|
form.running = false;
|
||||||
form.error = Some(reason.clone());
|
form.error = Some(reason.clone());
|
||||||
}
|
}
|
||||||
|
Some(UtilityView::Git(form)) => {
|
||||||
|
form.progress = None;
|
||||||
|
form.running = false;
|
||||||
|
form.error = Some(reason.clone());
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.authentication = AuthenticationView::Locked;
|
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}")));
|
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 => {
|
UtilityView::Help => {
|
||||||
content = content
|
content = content
|
||||||
.push(text("IronStorage Help").size(28))
|
.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 done = button("Done (Esc)");
|
||||||
let busy = matches!(utility, UtilityView::Settings(form) if form.saving)
|
let busy = matches!(utility, UtilityView::Settings(form) if form.saving)
|
||||||
|| matches!(utility, UtilityView::Recipients(form) if form.running)
|
|| 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 {
|
let done = if busy {
|
||||||
done
|
done
|
||||||
} else {
|
} else {
|
||||||
@@ -3132,6 +3501,29 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
|||||||
]
|
]
|
||||||
.spacing(8)
|
.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],
|
UtilityView::About | UtilityView::Help => row![done],
|
||||||
};
|
};
|
||||||
container(
|
container(
|
||||||
@@ -3566,6 +3958,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
|
|||||||
},
|
},
|
||||||
form.source.path().display()
|
form.source.path().display()
|
||||||
),
|
),
|
||||||
|
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
|
||||||
};
|
};
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
@@ -3586,6 +3979,107 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
|
|||||||
.into()
|
.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> {
|
fn generation_view(form: &GenerateForm) -> Element<'_, Message> {
|
||||||
let mut content = column![
|
let mut content = column![
|
||||||
text(if form.replacement {
|
text(if form.replacement {
|
||||||
@@ -3936,6 +4430,8 @@ mod tests {
|
|||||||
key: None,
|
key: None,
|
||||||
handle: None,
|
handle: None,
|
||||||
sensitive: SensitiveUiState::default(),
|
sensitive: SensitiveUiState::default(),
|
||||||
|
git_control: None,
|
||||||
|
git_progress: None,
|
||||||
authentication_generation: 0,
|
authentication_generation: 0,
|
||||||
operation_generation: 0,
|
operation_generation: 0,
|
||||||
tree_generation: 0,
|
tree_generation: 0,
|
||||||
@@ -4128,6 +4624,9 @@ mod tests {
|
|||||||
PendingAction::OpenEntry("other".to_owned()),
|
PendingAction::OpenEntry("other".to_owned()),
|
||||||
PendingAction::Reload("draft".to_owned()),
|
PendingAction::Reload("draft".to_owned()),
|
||||||
PendingAction::CloseWindow(window::Id::unique()),
|
PendingAction::CloseWindow(window::Id::unique()),
|
||||||
|
PendingAction::Git(DesktopGitRequest::Pull),
|
||||||
|
PendingAction::Git(DesktopGitRequest::Sync),
|
||||||
|
PendingAction::Git(DesktopGitRequest::Resolve(Vec::new())),
|
||||||
] {
|
] {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
action,
|
action,
|
||||||
@@ -4135,6 +4634,7 @@ mod tests {
|
|||||||
| PendingAction::OpenEntry(_)
|
| PendingAction::OpenEntry(_)
|
||||||
| PendingAction::Reload(_)
|
| PendingAction::Reload(_)
|
||||||
| PendingAction::CloseWindow(_)
|
| PendingAction::CloseWindow(_)
|
||||||
|
| PendingAction::Git(_)
|
||||||
));
|
));
|
||||||
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
|
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
|
||||||
}
|
}
|
||||||
@@ -4154,6 +4654,14 @@ mod tests {
|
|||||||
let _task = app.update(Message::CancelDiscard);
|
let _task = app.update(Message::CancelDiscard);
|
||||||
assert!(app.confirmation.is_none());
|
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 draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
|
||||||
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
|
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
|
||||||
app.navigation.replace_test_nodes(vec![
|
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]
|
#[test]
|
||||||
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
|
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
|
||||||
let (_temporary, storage) = fixture_storage();
|
let (_temporary, storage) = fixture_storage();
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
| UiAction::CopyEntry
|
| UiAction::CopyEntry
|
||||||
| UiAction::DeleteEntry
|
| UiAction::DeleteEntry
|
||||||
| UiAction::ToggleReveal
|
| UiAction::ToggleReveal
|
||||||
|
| UiAction::GitStatus
|
||||||
|
| UiAction::GitPull
|
||||||
|
| UiAction::GitPush
|
||||||
|
| UiAction::GitSync
|
||||||
| UiAction::Minimize => return None,
|
| UiAction::Minimize => return None,
|
||||||
};
|
};
|
||||||
Some(Accelerator::new(Some(modifiers), code))
|
Some(Accelerator::new(Some(modifiers), code))
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ use crate::{
|
|||||||
config::{Config, ConfigSettings, EditorCommand},
|
config::{Config, ConfigSettings, EditorCommand},
|
||||||
crypto::{KeyInfo, KeyStore, SecretProvider},
|
crypto::{KeyInfo, KeyStore, SecretProvider},
|
||||||
document::{DocumentError, EntryDocument, EntryDocumentService},
|
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},
|
mutation::{MutationOutcome, TreeMutator},
|
||||||
presentation::ClipboardTimeout,
|
presentation::ClipboardTimeout,
|
||||||
read::{FindResults, GrepResults, TreeModel, VaultReader},
|
read::{FindResults, GrepResults, TreeModel, VaultReader},
|
||||||
@@ -43,6 +48,8 @@ pub enum DesktopErrorKind {
|
|||||||
pub struct DesktopError {
|
pub struct DesktopError {
|
||||||
kind: DesktopErrorKind,
|
kind: DesktopErrorKind,
|
||||||
message: String,
|
message: String,
|
||||||
|
conflicts: Vec<GitConflict>,
|
||||||
|
git: Option<GitError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DesktopError {
|
impl DesktopError {
|
||||||
@@ -50,10 +57,35 @@ impl DesktopError {
|
|||||||
self.kind
|
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 {
|
fn new(kind: DesktopErrorKind, error: impl fmt::Display) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind,
|
kind,
|
||||||
message: error.to_string(),
|
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),
|
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 {
|
impl DesktopMutationRequest {
|
||||||
pub fn source(&self) -> &str {
|
pub fn source(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
@@ -218,6 +302,92 @@ impl DesktopStorage {
|
|||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.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> {
|
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
|
|||||||
@@ -1486,6 +1486,36 @@ impl GitRepository {
|
|||||||
Ok((pull, push))
|
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
|
/// Return one internally consistent, secret-free view of repository state
|
||||||
/// for frontends. The remote relation uses only the last fetched tracking
|
/// for frontends. The remote relation uses only the last fetched tracking
|
||||||
/// ref and therefore never performs network access.
|
/// ref and therefore never performs network access.
|
||||||
|
|||||||
@@ -341,6 +341,34 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -> TestResult {
|
fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -> TestResult {
|
||||||
let temporary = tempfile::tempdir()?;
|
let temporary = tempfile::tempdir()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user