Present iPhone Git actions (#51)
This commit is contained in:
@@ -2255,6 +2255,25 @@ impl GitRepository {
|
||||
self.commit_tree(message, tree)
|
||||
}
|
||||
|
||||
/// Stage every current worktree/index change and create one user-requested
|
||||
/// commit without exposing staging policy to a frontend.
|
||||
pub fn commit_all(&self, message: &str) -> Result<String, GitError> {
|
||||
let status = self.status()?;
|
||||
let paths = status
|
||||
.staged()
|
||||
.iter()
|
||||
.chain(status.unstaged())
|
||||
.map(|change| change.path().to_owned())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if paths.is_empty() {
|
||||
return Err(GitError::NoChanges);
|
||||
}
|
||||
self.stage_and_commit(&paths, message)?
|
||||
.ok_or(GitError::NoChanges)
|
||||
}
|
||||
|
||||
fn commit_tree(&self, message: &str, tree: gix::hash::ObjectId) -> Result<String, GitError> {
|
||||
validate_commit_message(message)?;
|
||||
let parent = self.repository.head_id().ok().map(|id| id.detach());
|
||||
|
||||
@@ -195,9 +195,38 @@ struct MobileAuthenticationStatus {
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
mutation_active: bool,
|
||||
repository_operation_active: bool,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MobileRepositoryOperation {
|
||||
Commit,
|
||||
Fetch,
|
||||
Pull,
|
||||
Push,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MobileRepositoryOperationError {
|
||||
Busy,
|
||||
DirtyEditor,
|
||||
OpenEditor,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(crate) struct MobileRepositoryOperationGuard<'a> {
|
||||
authentication: &'a MobileAuthentication,
|
||||
}
|
||||
|
||||
impl Drop for MobileRepositoryOperationGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut status) = self.authentication.status.lock() {
|
||||
status.repository_operation_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
pub struct MobileAuthentication {
|
||||
config: Config,
|
||||
@@ -232,6 +261,7 @@ impl MobileAuthentication {
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
mutation_active: false,
|
||||
repository_operation_active: false,
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
}),
|
||||
config,
|
||||
@@ -667,6 +697,7 @@ impl MobileAuthentication {
|
||||
field: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_active_document(path)?;
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||
@@ -824,6 +855,7 @@ impl MobileAuthentication {
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
self.ensure_repository_idle()?;
|
||||
let mut draft = self
|
||||
.status()?
|
||||
.editors
|
||||
@@ -939,10 +971,10 @@ impl MobileAuthentication {
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
if status.mutation_active {
|
||||
if status.mutation_active || status.repository_operation_active {
|
||||
return Err(entry_detail(
|
||||
"Password Action In Progress",
|
||||
"wait for the current move, copy, or delete action to finish",
|
||||
"wait for the current password-store action to finish",
|
||||
));
|
||||
}
|
||||
let id = status.next_editor_id;
|
||||
@@ -960,7 +992,7 @@ impl MobileAuthentication {
|
||||
fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
// ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
|
||||
if status.mutation_active {
|
||||
if status.mutation_active || status.repository_operation_active {
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Password Action In Progress",
|
||||
@@ -971,6 +1003,44 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reserve_repository_operation(
|
||||
&self,
|
||||
operation: MobileRepositoryOperation,
|
||||
) -> Result<MobileRepositoryOperationGuard<'_>, MobileRepositoryOperationError> {
|
||||
let mut status = self
|
||||
.status
|
||||
.lock()
|
||||
.map_err(|_| MobileRepositoryOperationError::Unavailable)?;
|
||||
let dirty_editor = status
|
||||
.editors
|
||||
.values()
|
||||
.any(|draft| draft.document().is_modified());
|
||||
if let Some(error) = repository_operation_conflict(
|
||||
operation,
|
||||
status.mutation_active || status.repository_operation_active,
|
||||
!status.editors.is_empty(),
|
||||
dirty_editor,
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
status.repository_operation_active = true;
|
||||
drop(status);
|
||||
Ok(MobileRepositoryOperationGuard {
|
||||
authentication: self,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> {
|
||||
if self.status()?.repository_operation_active {
|
||||
Err(entry_detail(
|
||||
"Repository Action In Progress",
|
||||
"wait for the current commit, fetch, pull, or push action to finish",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn release_entry_mutation(&self) {
|
||||
if let Ok(mut status) = self.status.lock() {
|
||||
status.mutation_active = false;
|
||||
@@ -1061,6 +1131,27 @@ impl MobileAuthentication {
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_operation_conflict(
|
||||
operation: MobileRepositoryOperation,
|
||||
busy: bool,
|
||||
open_editor: bool,
|
||||
dirty_editor: bool,
|
||||
) -> Option<MobileRepositoryOperationError> {
|
||||
if busy {
|
||||
Some(MobileRepositoryOperationError::Busy)
|
||||
} else if operation == MobileRepositoryOperation::Pull && open_editor {
|
||||
Some(if dirty_editor {
|
||||
MobileRepositoryOperationError::DirtyEditor
|
||||
} else {
|
||||
MobileRepositoryOperationError::OpenEditor
|
||||
})
|
||||
} else if operation == MobileRepositoryOperation::Push && dirty_editor {
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
handle: NativeAuthenticationHandle,
|
||||
fingerprint: &'a str,
|
||||
@@ -1198,3 +1289,34 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn repository_actions_protect_editors_and_serialize_mutations() {
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, false),
|
||||
Some(MobileRepositoryOperationError::OpenEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, true),
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Push, false, true, true),
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Fetch, false, true, true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Commit, true, false, false),
|
||||
Some(MobileRepositoryOperationError::Busy)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ use std::{
|
||||
use crate::{
|
||||
config::{Config, ConfigError, GitRemote},
|
||||
git::{
|
||||
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl,
|
||||
GitProgressPhase, GitRepository, PullOutcome,
|
||||
FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, PushOutcome,
|
||||
},
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||
},
|
||||
repository::{Repository, RepositoryError},
|
||||
secret_store::{
|
||||
@@ -46,6 +49,35 @@ pub enum MobileHomeChangeStatus {
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileHomeActionKind {
|
||||
Commit,
|
||||
Fetch,
|
||||
Pull,
|
||||
Push,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomeAction {
|
||||
kind: MobileHomeActionKind,
|
||||
title: &'static str,
|
||||
system_image: &'static str,
|
||||
}
|
||||
|
||||
impl MobileHomeAction {
|
||||
pub fn kind(&self) -> MobileHomeActionKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
self.system_image
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GitChangeKind> for MobileHomeChangeStatus {
|
||||
fn from(kind: GitChangeKind) -> Self {
|
||||
match kind {
|
||||
@@ -62,6 +94,7 @@ pub struct MobileHomeSummaryRow {
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
impl MobileHomeSummaryRow {
|
||||
@@ -80,6 +113,10 @@ impl MobileHomeSummaryRow {
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
&self.actions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -126,6 +163,7 @@ pub struct MobileHomeCommit {
|
||||
system_image: String,
|
||||
timestamp: i64,
|
||||
changes: Vec<MobileHomeChange>,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
impl MobileHomeCommit {
|
||||
@@ -152,6 +190,10 @@ impl MobileHomeCommit {
|
||||
pub fn changes(&self) -> &[MobileHomeChange] {
|
||||
&self.changes
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
&self.actions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -227,6 +269,7 @@ pub enum MobileHomePhase {
|
||||
Authenticating,
|
||||
Receiving,
|
||||
Integrating,
|
||||
Sending,
|
||||
Finishing,
|
||||
}
|
||||
|
||||
@@ -236,7 +279,8 @@ impl From<GitProgressPhase> for MobileHomePhase {
|
||||
GitProgressPhase::Validating => Self::Validating,
|
||||
GitProgressPhase::Authenticating => Self::Authenticating,
|
||||
GitProgressPhase::Receiving => Self::Receiving,
|
||||
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
|
||||
GitProgressPhase::Integrating => Self::Integrating,
|
||||
GitProgressPhase::Sending => Self::Sending,
|
||||
GitProgressPhase::Refreshing => Self::Finishing,
|
||||
}
|
||||
}
|
||||
@@ -244,12 +288,17 @@ impl From<GitProgressPhase> for MobileHomePhase {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomeProgress {
|
||||
action: Option<MobileHomeActionKind>,
|
||||
phase: MobileHomePhase,
|
||||
title: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileHomeProgress {
|
||||
pub fn action(&self) -> Option<MobileHomeActionKind> {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> MobileHomePhase {
|
||||
self.phase
|
||||
}
|
||||
@@ -264,32 +313,34 @@ impl MobileHomeProgress {
|
||||
}
|
||||
|
||||
pub struct MobileHomeOperation {
|
||||
authentication: Arc<MobileAuthentication>,
|
||||
control: GitOperationControl,
|
||||
phase: Arc<Mutex<MobileHomePhase>>,
|
||||
action: Mutex<Option<MobileHomeActionKind>>,
|
||||
}
|
||||
|
||||
impl Default for MobileHomeOperation {
|
||||
fn default() -> Self {
|
||||
impl MobileHomeOperation {
|
||||
pub fn new(authentication: Arc<MobileAuthentication>) -> Self {
|
||||
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
|
||||
let observed = Arc::clone(&phase);
|
||||
Self {
|
||||
authentication,
|
||||
control: GitOperationControl::new(move |phase| {
|
||||
if let Ok(mut current) = observed.lock() {
|
||||
*current = phase.into();
|
||||
}
|
||||
}),
|
||||
phase,
|
||||
action: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileHomeOperation {
|
||||
pub fn cancel(&self) {
|
||||
self.control.cancel();
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> MobileHomeProgress {
|
||||
progress_copy(
|
||||
self.action.lock().ok().and_then(|action| *action),
|
||||
self.phase
|
||||
.lock()
|
||||
.map_or(MobileHomePhase::Validating, |phase| *phase),
|
||||
@@ -320,15 +371,53 @@ impl MobileHomeOperation {
|
||||
None,
|
||||
);
|
||||
}
|
||||
storage.refresh(now, &self.control)
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
storage.fetch(now, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
|
||||
pub fn commit(&self, message: &str) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if message.trim().is_empty() || message.contains('\0') {
|
||||
return Err(MobileHomeError::invalid_commit_message());
|
||||
}
|
||||
self.run(MobileHomeActionKind::Commit, || {
|
||||
MobileHomeStorage::load()?.commit(message, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
||||
self.run(MobileHomeActionKind::Pull, || {
|
||||
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Push, || {
|
||||
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
action: MobileHomeActionKind,
|
||||
operation: impl FnOnce() -> Result<MobileHomePage, MobileHomeError>,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if let Ok(mut current) = self.action.lock() {
|
||||
*current = Some(action);
|
||||
}
|
||||
let reservation = self
|
||||
.authentication
|
||||
.reserve_repository_operation(repository_operation(action))
|
||||
.map_err(MobileHomeError::from_reservation)?;
|
||||
let result = operation();
|
||||
drop(reservation);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,27 +445,50 @@ impl MobileHomeStorage {
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
fn commit(
|
||||
&self,
|
||||
message: &str,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
control
|
||||
.report(GitProgressPhase::Validating)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let commit = self
|
||||
.git
|
||||
.commit_all(message)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
self.config.mobile_home_refreshed_at(),
|
||||
Some(commit_notice(&commit)),
|
||||
)
|
||||
.map_err(MobileHomeError::after_commit)
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let store = self.credentials()?;
|
||||
let refreshed = self.git.fetch_with_transport_controlled(
|
||||
let fetched = self.git.fetch_with_transport_controlled(
|
||||
&self.remote,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
);
|
||||
let locked = store.lock();
|
||||
refreshed.map_err(MobileHomeError::from_git)?;
|
||||
let outcome = fetched.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
return Err(MobileHomeError::partial_secret(error));
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.current_page(now, None)
|
||||
self.current_page(now, Some(fetch_notice(&outcome)))
|
||||
}
|
||||
|
||||
fn pull(
|
||||
@@ -404,6 +516,31 @@ impl MobileHomeStorage {
|
||||
.map_err(MobileHomeError::after_pull)
|
||||
}
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let store = self.credentials()?;
|
||||
let pushed = self.git.push_with_transport_controlled(
|
||||
&self.remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::ReqwestGitTransport,
|
||||
control,
|
||||
);
|
||||
let locked = store.lock();
|
||||
let outcome = pushed.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
return Err(MobileHomeError::partial_secret(error));
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.current_page(now, Some(push_notice(&outcome)))
|
||||
.map_err(MobileHomeError::after_push)
|
||||
}
|
||||
|
||||
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
|
||||
let store = NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
@@ -463,6 +600,7 @@ pub enum MobileHomeErrorKind {
|
||||
Authentication,
|
||||
Conflict,
|
||||
DirtyLocalChanges,
|
||||
NoChanges,
|
||||
Offline,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
@@ -557,6 +695,17 @@ impl MobileHomeError {
|
||||
title: "Local Changes Need Attention",
|
||||
detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
|
||||
},
|
||||
GitError::NoChanges => Self {
|
||||
kind: MobileHomeErrorKind::NoChanges,
|
||||
title: "Nothing to Commit",
|
||||
detail: "The password-store working tree has no changes to commit.".to_owned(),
|
||||
},
|
||||
GitError::NonFastForward => Self {
|
||||
kind: MobileHomeErrorKind::Conflict,
|
||||
title: "Pull Before Pushing",
|
||||
detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push."
|
||||
.to_owned(),
|
||||
},
|
||||
GitError::NetworkUnavailable => Self {
|
||||
kind: MobileHomeErrorKind::Offline,
|
||||
title: "Server Is Offline",
|
||||
@@ -589,6 +738,43 @@ impl MobileHomeError {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_commit_message() -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::NoChanges,
|
||||
title: "Commit Message Required",
|
||||
detail: "Enter a non-empty commit message and try again.".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_reservation(error: MobileRepositoryOperationError) -> Self {
|
||||
match error {
|
||||
MobileRepositoryOperationError::DirtyEditor => Self {
|
||||
kind: MobileHomeErrorKind::DirtyLocalChanges,
|
||||
title: "Unsaved Entry Edits",
|
||||
detail: "Save or discard the open entry editor before pulling or pushing."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::OpenEditor => Self {
|
||||
kind: MobileHomeErrorKind::DirtyLocalChanges,
|
||||
title: "Close the Entry Editor",
|
||||
detail: "Close the open entry editor before pulling so its source cannot change underneath it."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::Busy => Self {
|
||||
kind: MobileHomeErrorKind::Interrupted,
|
||||
title: "Password-Store Action in Progress",
|
||||
detail: "Wait for the current password-store action to finish, then retry."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::Unavailable => Self {
|
||||
kind: MobileHomeErrorKind::Repository,
|
||||
title: "Password-Store State Is Unavailable",
|
||||
detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry."
|
||||
.to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secret(error: SecretStoreError) -> Self {
|
||||
let detail = match error {
|
||||
SecretStoreError::Denied => "Access to the application token was denied.",
|
||||
@@ -631,6 +817,24 @@ impl MobileHomeError {
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn after_commit(_error: Self) -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::PartialProgress,
|
||||
title: "Commit Completed, Status Unavailable",
|
||||
detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn after_push(_error: Self) -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::PartialProgress,
|
||||
title: "Push Completed, Status Unavailable",
|
||||
detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileHomeError {
|
||||
@@ -674,6 +878,11 @@ fn page_from_divergence(
|
||||
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||
detail: "Tracked HTTPS branch".to_owned(),
|
||||
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
||||
actions: vec![
|
||||
mobile_action(MobileHomeActionKind::Fetch),
|
||||
mobile_action(MobileHomeActionKind::Pull),
|
||||
mobile_action(MobileHomeActionKind::Push),
|
||||
],
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "divergence".to_owned(),
|
||||
@@ -684,6 +893,13 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"arrow.triangle.2.circlepath".to_owned()
|
||||
},
|
||||
actions: [
|
||||
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
||||
(ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "working-tree".to_owned(),
|
||||
@@ -702,6 +918,11 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
},
|
||||
];
|
||||
MobileHomePage {
|
||||
@@ -765,6 +986,11 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
|
||||
},
|
||||
timestamp: commit.timestamp(),
|
||||
changes,
|
||||
actions: vec![mobile_action(if incoming {
|
||||
MobileHomeActionKind::Pull
|
||||
} else {
|
||||
MobileHomeActionKind::Push
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,23 +1107,113 @@ fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
|
||||
let (title, detail) = match phase {
|
||||
MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."),
|
||||
MobileHomePhase::Authenticating => (
|
||||
fn commit_notice(commit: &str) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: "Changes Committed".to_owned(),
|
||||
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
||||
system_image: "checkmark.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: if outcome.received_pack() {
|
||||
"Remote Status Updated"
|
||||
} else {
|
||||
"Remote Already Current"
|
||||
}
|
||||
.to_owned(),
|
||||
detail: if outcome.received_pack() {
|
||||
format!(
|
||||
"Received new objects from {} without changing the local password store.",
|
||||
outcome.remote()
|
||||
)
|
||||
} else {
|
||||
format!("{} has no new objects for this clone.", outcome.remote())
|
||||
},
|
||||
system_image: "arrow.clockwise.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
|
||||
let unchanged = outcome.old() == Some(outcome.new_id());
|
||||
MobileHomeNotice {
|
||||
title: if unchanged {
|
||||
"Remote Already Current"
|
||||
} else {
|
||||
"Changes Pushed"
|
||||
}
|
||||
.to_owned(),
|
||||
detail: if unchanged {
|
||||
format!(
|
||||
"{}/{} already points to the local commit.",
|
||||
outcome.remote(),
|
||||
outcome.branch()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Updated {}/{} with local commits.",
|
||||
outcome.remote(),
|
||||
outcome.branch()
|
||||
)
|
||||
},
|
||||
system_image: "arrow.up.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
||||
let (title, system_image) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
|
||||
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
|
||||
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
|
||||
};
|
||||
MobileHomeAction {
|
||||
kind,
|
||||
title,
|
||||
system_image,
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
||||
match action {
|
||||
MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit,
|
||||
MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch,
|
||||
MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull,
|
||||
MobileHomeActionKind::Push => MobileRepositoryOperation::Push,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_copy(
|
||||
action: Option<MobileHomeActionKind>,
|
||||
phase: MobileHomePhase,
|
||||
) -> MobileHomeProgress {
|
||||
let (title, detail) = match (action, phase) {
|
||||
(Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => (
|
||||
"Committing Changes",
|
||||
"Staging current password-store changes and creating the commit in Rust.",
|
||||
),
|
||||
(_, MobileHomePhase::Validating) => {
|
||||
("Checking Home", "Validating local and remote Git state.")
|
||||
}
|
||||
(_, MobileHomePhase::Authenticating) => (
|
||||
"Authenticating",
|
||||
"Reading the application token from protected storage.",
|
||||
),
|
||||
MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."),
|
||||
MobileHomePhase::Integrating => (
|
||||
(_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."),
|
||||
(_, MobileHomePhase::Integrating) => (
|
||||
"Updating Password Store",
|
||||
"Integrating fetched commits into the local clone.",
|
||||
),
|
||||
MobileHomePhase::Finishing => {
|
||||
(_, MobileHomePhase::Sending) => {
|
||||
("Pushing Changes", "Sending local Git objects over HTTPS.")
|
||||
}
|
||||
(_, MobileHomePhase::Finishing) => {
|
||||
("Updating Home", "Building current activity and divergence.")
|
||||
}
|
||||
};
|
||||
MobileHomeProgress {
|
||||
action,
|
||||
phase,
|
||||
title: title.to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
@@ -935,8 +1251,8 @@ mod tests {
|
||||
use crate::git::{GitChangeKind, GitError};
|
||||
|
||||
use super::{
|
||||
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
|
||||
mobile_change,
|
||||
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
||||
MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -974,6 +1290,20 @@ mod tests {
|
||||
assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
|
||||
let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
|
||||
assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
|
||||
let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward);
|
||||
assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict);
|
||||
let no_changes = super::MobileHomeError::from_git(GitError::NoChanges);
|
||||
assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges);
|
||||
assert!(!offline.to_string().contains("token-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_and_progress_are_typed_before_swift_presentation() {
|
||||
let push = mobile_action(MobileHomeActionKind::Push);
|
||||
assert_eq!(push.title(), "Push");
|
||||
assert_eq!(push.system_image(), "arrow.up.circle");
|
||||
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
||||
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
||||
assert_eq!(progress.title(), "Pushing Changes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,23 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_commit_stages_every_current_change_in_storage() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let store = Repository::open(temporary.path())?;
|
||||
let git = GitRepository::init(&store, identity())?;
|
||||
fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?;
|
||||
fs::write(temporary.path().join("mail.gpg"), b"ciphertext")?;
|
||||
|
||||
let commit = git.commit_all("Save mobile changes.")?;
|
||||
|
||||
assert_eq!(commit.len(), 40);
|
||||
assert!(git.status()?.is_clean());
|
||||
assert_eq!(git.log(Some(1))?[0].message(), "Save mobile changes.");
|
||||
assert_eq!(git.commit_all("Nothing changed"), Err(GitError::NoChanges));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repository_selection_is_innermost() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
|
||||
Reference in New Issue
Block a user