Implement embedded Git synchronization TUI
This commit is contained in:
@@ -17,6 +17,7 @@ pub mod workflow;
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::PathBuf,
|
||||
sync::mpsc::{self, Sender},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -62,6 +63,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
let executor = AsyncExecutor::new();
|
||||
let mut clipboard_cancellations = ClipboardCancellations::default();
|
||||
let mut authentication = None;
|
||||
let mut git_control = None;
|
||||
let mut authentication_initialized = false;
|
||||
let mut key_resolver = KeyResolver::default();
|
||||
let startup = app.begin_latest_request();
|
||||
@@ -73,6 +75,9 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
for result in executor.drain() {
|
||||
app.apply_result(result);
|
||||
}
|
||||
if !app.git_pending() {
|
||||
git_control = None;
|
||||
}
|
||||
if !authentication_initialized
|
||||
&& let (Some(config), Some(key)) = (app.config(), app.default_key())
|
||||
{
|
||||
@@ -85,7 +90,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
if let Some(coordinator) = authentication.as_mut()
|
||||
&& let Some(event) = coordinator.completion()
|
||||
{
|
||||
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||
apply_authentication_event(&mut app, coordinator, &executor, &mut git_control, event);
|
||||
}
|
||||
|
||||
let size = terminal.size()?;
|
||||
@@ -95,7 +100,13 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
app.tick();
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
if let Some(event) = coordinator.poll_lease() {
|
||||
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||
apply_authentication_event(
|
||||
&mut app,
|
||||
coordinator,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
event,
|
||||
);
|
||||
}
|
||||
app.update_remaining_lease(coordinator.remaining_time());
|
||||
}
|
||||
@@ -107,7 +118,13 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
if let Some(coordinator) = authentication.as_mut()
|
||||
&& let Some(event) = coordinator.touch_user_activity()
|
||||
{
|
||||
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||
apply_authentication_event(
|
||||
&mut app,
|
||||
coordinator,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
event,
|
||||
);
|
||||
}
|
||||
if let Some(effect) = app.handle_command_input(key.code, key.modifiers) {
|
||||
key_resolver.reset();
|
||||
@@ -116,6 +133,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
effect,
|
||||
&mut authentication,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
&mut clipboard_cancellations,
|
||||
);
|
||||
continue;
|
||||
@@ -127,6 +145,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
effect,
|
||||
&mut authentication,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
&mut clipboard_cancellations,
|
||||
);
|
||||
continue;
|
||||
@@ -154,6 +173,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
effect,
|
||||
&mut authentication,
|
||||
&executor,
|
||||
&mut git_control,
|
||||
&mut clipboard_cancellations,
|
||||
);
|
||||
}
|
||||
@@ -184,6 +204,7 @@ fn apply_app_effect(
|
||||
effect: AppEffect,
|
||||
authentication: &mut Option<AuthenticationCoordinator>,
|
||||
executor: &AsyncExecutor,
|
||||
git_control: &mut Option<ironstorage::git::GitOperationControl>,
|
||||
clipboard_cancellations: &mut ClipboardCancellations,
|
||||
) {
|
||||
match effect {
|
||||
@@ -213,6 +234,34 @@ fn apply_app_effect(
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::AuthenticateGit(request) => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
app.begin_git_operation(crate::command::operation_name(
|
||||
&ironstorage::command::CommandRequest::Git(request.clone()),
|
||||
));
|
||||
coordinator.request_git(request);
|
||||
} else {
|
||||
app.authentication_failed(
|
||||
"operating-system secure storage is unavailable".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::CancelGit => {
|
||||
if let Some(control) = git_control.as_ref() {
|
||||
control.cancel();
|
||||
}
|
||||
}
|
||||
AppEffect::ResolveGit(resolutions) => {
|
||||
if let Some(config) = app.config().cloned() {
|
||||
let token = app.begin_request();
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || {
|
||||
execute_git_resolution(&config, &resolutions, &control)
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEffect::CopyFocused(value) => {
|
||||
if let Some(config) = app.config().cloned() {
|
||||
let (cancel, cancellation) = mpsc::channel();
|
||||
@@ -280,13 +329,36 @@ fn apply_app_effect(
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEffect::RunCommand(ironstorage::command::CommandRequest::Git(request)) => {
|
||||
if git_requires_authentication(&request) {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
app.begin_git_operation(crate::command::operation_name(
|
||||
&ironstorage::command::CommandRequest::Git(request.clone()),
|
||||
));
|
||||
coordinator.request_git(request);
|
||||
} else {
|
||||
app.authentication_failed(
|
||||
"operating-system secure storage is unavailable".to_owned(),
|
||||
);
|
||||
}
|
||||
} else if let Some(config) = app.config().cloned() {
|
||||
app.begin_git_operation(crate::command::operation_name(
|
||||
&ironstorage::command::CommandRequest::Git(request.clone()),
|
||||
));
|
||||
let token = app.begin_request();
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || execute_git(&config, request, None, &control));
|
||||
}
|
||||
}
|
||||
AppEffect::RunCommand(request) => {
|
||||
app.report_status(format!(
|
||||
"{} is not implemented by this terminal workflow yet",
|
||||
crate::command::operation_name(&request)
|
||||
));
|
||||
}
|
||||
AppEffect::OpenWorkflow(_) | AppEffect::None => {}
|
||||
AppEffect::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +366,323 @@ fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
|
||||
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
}
|
||||
|
||||
fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
ironstorage::command::GitRequest::Diff { .. }
|
||||
| ironstorage::command::GitRequest::Fetch { .. }
|
||||
| ironstorage::command::GitRequest::Pull { .. }
|
||||
| ironstorage::command::GitRequest::Push { .. }
|
||||
| ironstorage::command::GitRequest::Sync { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_git(
|
||||
config: &ironstorage::config::Config,
|
||||
request: ironstorage::command::GitRequest,
|
||||
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
|
||||
control: &ironstorage::git::GitOperationControl,
|
||||
) -> Result<AsyncPayload, String> {
|
||||
use ironstorage::{
|
||||
command::{GitConfigRequest, GitRemoteRequest, GitRequest},
|
||||
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
|
||||
control
|
||||
.report(ironstorage::git::GitProgressPhase::Validating)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
|
||||
let snapshot_remote = match &request {
|
||||
GitRequest::Fetch { remote }
|
||||
| GitRequest::Pull { remote, .. }
|
||||
| GitRequest::Push { remote, .. }
|
||||
| GitRequest::Sync { remote } => remote.clone(),
|
||||
_ => None,
|
||||
};
|
||||
let mut git = match request {
|
||||
GitRequest::Init => GitRepository::init(&repository, GitIdentity::ironstorage()),
|
||||
_ => GitRepository::open(&repository, GitIdentity::ironstorage()),
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut changed_snapshot = false;
|
||||
let mut details = None;
|
||||
let operation = match request {
|
||||
GitRequest::Init => "Initialized embedded Git repository".to_owned(),
|
||||
GitRequest::Status => "Git status refreshed".to_owned(),
|
||||
GitRequest::Log { maximum } => {
|
||||
let log = git
|
||||
.log(maximum.map(std::num::NonZeroUsize::get))
|
||||
.map_err(|error| error.to_string())?;
|
||||
details = Some(SecretBytes::new(
|
||||
log.iter()
|
||||
.map(|entry| {
|
||||
format!(
|
||||
"{} {}\n",
|
||||
&entry.id()[..entry.id().len().min(12)],
|
||||
entry.message()
|
||||
)
|
||||
})
|
||||
.collect::<String>()
|
||||
.into_bytes(),
|
||||
));
|
||||
format!("Git log: {} commit(s)", log.len())
|
||||
}
|
||||
GitRequest::Diff { paths } => {
|
||||
let handle = credentials
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Git diff requires authentication".to_owned())?;
|
||||
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
|
||||
.map_err(|error| error.to_string())?;
|
||||
details = Some(
|
||||
git.render_diff(
|
||||
&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>(),
|
||||
&keys,
|
||||
handle,
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
"Rendered helper-free Git diff".to_owned()
|
||||
}
|
||||
GitRequest::Add { paths } => {
|
||||
git.stage(&paths.into_iter().map(PathBuf::from).collect::<Vec<_>>())
|
||||
.map_err(|error| error.to_string())?;
|
||||
"Staged Git paths".to_owned()
|
||||
}
|
||||
GitRequest::Commit { message } => {
|
||||
let id = git.commit(&message).map_err(|error| error.to_string())?;
|
||||
format!("Created commit {}", &id[..id.len().min(12)])
|
||||
}
|
||||
GitRequest::Remote(remote) => match remote {
|
||||
GitRemoteRequest::List => {
|
||||
details = Some(SecretBytes::new(git.remotes().join("\n").into_bytes()));
|
||||
"Listed Git remotes".to_owned()
|
||||
}
|
||||
GitRemoteRequest::GetUrl { name } => {
|
||||
details = Some(SecretBytes::new(
|
||||
git.remote_url(&name)
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_bytes(),
|
||||
));
|
||||
format!("Git remote {name}")
|
||||
}
|
||||
GitRemoteRequest::Add { name, url } => {
|
||||
git.add_remote(&name, &url)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!("Added Git remote {name}")
|
||||
}
|
||||
GitRemoteRequest::SetUrl { name, url } => {
|
||||
git.set_remote_url(&name, &url)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!("Updated Git remote {name}")
|
||||
}
|
||||
GitRemoteRequest::Remove { name } => {
|
||||
git.remove_remote(&name)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!("Removed Git remote {name}")
|
||||
}
|
||||
},
|
||||
GitRequest::Config(request) => match request {
|
||||
GitConfigRequest::Get { key } => {
|
||||
let value = git
|
||||
.config_get(&key)
|
||||
.map_err(|error| error.to_string())?
|
||||
.unwrap_or_default();
|
||||
details = Some(SecretBytes::new(value.into_bytes()));
|
||||
format!("Git configuration {key}")
|
||||
}
|
||||
GitConfigRequest::Set { key, value } => {
|
||||
git.config_set(&key, &value)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!("Updated Git configuration {key}")
|
||||
}
|
||||
},
|
||||
GitRequest::Fetch { remote } => {
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git fetch requires authentication".to_owned())?;
|
||||
let outcome = git
|
||||
.fetch_with_transport_controlled(
|
||||
configured,
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!(
|
||||
"Fetched {}{}",
|
||||
outcome.remote(),
|
||||
if outcome.received_pack() {
|
||||
" (received objects)"
|
||||
} else {
|
||||
" (up to date)"
|
||||
}
|
||||
)
|
||||
}
|
||||
GitRequest::Pull { remote, branch } => {
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git pull requires authentication".to_owned())?;
|
||||
match git.pull_with_transport_controlled(
|
||||
configured,
|
||||
branch.as_deref(),
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
changed_snapshot = true;
|
||||
format!("Git pull completed: {outcome:?}")
|
||||
}
|
||||
Err(GitError::MergeConflicts { conflicts }) => {
|
||||
let snapshot = git
|
||||
.snapshot(Some(configured), 10)
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok(AsyncPayload::GitFinished {
|
||||
snapshot: Box::new(snapshot),
|
||||
tree: None,
|
||||
message: format!(
|
||||
"Git pull requires resolution of {} conflict(s)",
|
||||
conflicts.len()
|
||||
),
|
||||
conflicts,
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
GitRequest::Push { remote, branch } => {
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git push requires authentication".to_owned())?;
|
||||
let outcome = git
|
||||
.push_with_transport_controlled(
|
||||
configured,
|
||||
branch.as_deref(),
|
||||
handle,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
format!(
|
||||
"Pushed {} {}",
|
||||
outcome.remote(),
|
||||
&outcome.new_id()[..outcome.new_id().len().min(12)]
|
||||
)
|
||||
}
|
||||
GitRequest::Sync { remote } => {
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
|
||||
let pull = match git.pull_with_transport_controlled(
|
||||
configured,
|
||||
None,
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(GitError::MergeConflicts { conflicts }) => {
|
||||
let snapshot = git
|
||||
.snapshot(Some(configured), 10)
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok(AsyncPayload::GitFinished {
|
||||
snapshot: Box::new(snapshot),
|
||||
tree: None,
|
||||
message: format!(
|
||||
"Git synchronization requires resolution of {} conflict(s)",
|
||||
conflicts.len()
|
||||
),
|
||||
conflicts,
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let push = git
|
||||
.push_with_transport_controlled(
|
||||
configured,
|
||||
None,
|
||||
handle,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
changed_snapshot = true;
|
||||
format!(
|
||||
"Git synchronization completed: {pull:?}; pushed {}",
|
||||
&push.new_id()[..push.new_id().len().min(12)]
|
||||
)
|
||||
}
|
||||
};
|
||||
control
|
||||
.report(ironstorage::git::GitProgressPhase::Refreshing)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let configured = config.git_remote(snapshot_remote.as_deref());
|
||||
let snapshot = git
|
||||
.snapshot(configured, 10)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let tree = if changed_snapshot {
|
||||
Some(load_tree(config)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AsyncPayload::GitFinished {
|
||||
snapshot: Box::new(snapshot),
|
||||
tree,
|
||||
message: operation,
|
||||
conflicts: Vec::new(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_git_resolution(
|
||||
config: &ironstorage::config::Config,
|
||||
resolutions: &[ironstorage::git::GitConflictResolution],
|
||||
control: &ironstorage::git::GitOperationControl,
|
||||
) -> Result<AsyncPayload, String> {
|
||||
use ironstorage::git::{GitIdentity, GitRepository};
|
||||
control
|
||||
.report(ironstorage::git::GitProgressPhase::Integrating)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let configured = config
|
||||
.git_remote(None)
|
||||
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
|
||||
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||
.map_err(|error| error.to_string())?;
|
||||
git.resolve_fetched(configured, None, resolutions)
|
||||
.map_err(|error| error.to_string())?;
|
||||
control
|
||||
.report(ironstorage::git::GitProgressPhase::Refreshing)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let snapshot = git
|
||||
.snapshot(Some(configured), 10)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let tree = load_tree(config)?;
|
||||
Ok(AsyncPayload::GitFinished {
|
||||
snapshot: Box::new(snapshot),
|
||||
tree: Some(tree),
|
||||
message: "Merge conflicts resolved and committed".to_owned(),
|
||||
conflicts: Vec::new(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_startup() -> Result<StartupData, String> {
|
||||
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
||||
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||
@@ -323,6 +712,7 @@ fn apply_authentication_event(
|
||||
app: &mut App,
|
||||
coordinator: &AuthenticationCoordinator,
|
||||
executor: &AsyncExecutor,
|
||||
git_control: &mut Option<ironstorage::git::GitOperationControl>,
|
||||
event: AuthenticationEvent,
|
||||
) {
|
||||
match event {
|
||||
@@ -364,6 +754,21 @@ fn apply_authentication_event(
|
||||
))
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Granted(AuthenticationTarget::Git(request)) => {
|
||||
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
|
||||
app.authentication_failed(
|
||||
"authentication completed without an active secure-store lease".to_owned(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let token = app.begin_request();
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || {
|
||||
execute_git(&config, request, Some(handle), &control)
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Failed { workflow, message } => {
|
||||
if workflow {
|
||||
app.workflow_authentication_failed(message);
|
||||
|
||||
Reference in New Issue
Block a user