Implement embedded Git synchronization TUI

This commit is contained in:
Hermes Agent
2026-08-10 10:39:31 +00:00
parent ce3e4a9f79
commit 1850846696
8 changed files with 1402 additions and 36 deletions

View File

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