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

@@ -9,6 +9,7 @@ use ironstorage::{
config::Config,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
presentation::ClipboardDisposition,
read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes,
@@ -79,6 +80,29 @@ pub struct WorkflowSuccess {
pub status: String,
}
#[derive(Debug)]
pub struct GitView {
snapshot: GitSnapshot,
message: String,
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
}
impl GitView {
pub fn snapshot(&self) -> &GitSnapshot {
&self.snapshot
}
pub fn message(&self) -> &str {
&self.message
}
pub fn conflicts(&self) -> &[GitConflict] {
&self.conflicts
}
pub fn details(&self) -> Option<&SecretBytes> {
self.details.as_ref()
}
}
#[derive(Debug)]
pub enum AsyncPayload {
Startup(Box<StartupData>),
@@ -106,6 +130,14 @@ pub enum AsyncPayload {
result: Result<WriteOutcome, EditorSaveFailure>,
},
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
GitProgress(GitProgressPhase),
GitFinished {
snapshot: Box<GitSnapshot>,
tree: Option<TreeModel>,
message: String,
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -160,7 +192,9 @@ pub enum AppEffect {
editor: Box<EntryEditor>,
},
AuthenticateWorkflow(Box<WorkflowSubmission>),
OpenWorkflow(WorkflowAction),
AuthenticateGit(ironstorage::command::GitRequest),
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
RunCommand(CommandRequest),
ManualLock,
}
@@ -193,6 +227,8 @@ pub struct App {
workflow: Option<WorkflowForm>,
workflow_pending: bool,
grep_view: Option<GrepView>,
git_view: Option<GitView>,
git_pending: bool,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
@@ -231,6 +267,8 @@ impl App {
workflow: None,
workflow_pending: false,
grep_view: None,
git_view: None,
git_pending: false,
remaining_lease: None,
terminal_size: (0, 0),
ticks: 0,
@@ -321,6 +359,19 @@ impl App {
self.grep_view.as_ref()
}
pub fn git_view(&self) -> Option<&GitView> {
self.git_view.as_ref()
}
pub fn git_pending(&self) -> bool {
self.git_pending
}
pub fn begin_git_operation(&mut self, label: &str) {
self.git_pending = true;
self.status = format!("{label} queued for secure-storage authentication…");
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease
}
@@ -373,9 +424,12 @@ impl App {
}
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
return ResultDisposition::Stale;
}
if !matches!(result.payload, Ok(AsyncPayload::GitProgress(_))) {
self.pending.remove(&result.token.id);
}
match result.payload {
Ok(AsyncPayload::Startup(startup)) => {
self.status = format!("Vault: {}", startup.config.vault().display());
@@ -543,7 +597,36 @@ impl App {
}
}
}
Ok(AsyncPayload::GitProgress(phase)) => {
self.status = format!("Git {}… (Esc cancels)", git_phase_name(phase));
}
Ok(AsyncPayload::GitFinished {
snapshot,
tree,
message,
conflicts,
details,
}) => {
self.git_pending = false;
if let Some(tree) = tree {
self.sidebar.replace_tree(&tree);
}
self.git_view = Some(GitView {
snapshot: *snapshot,
message: message.clone(),
conflicts,
details,
});
self.grep_view = None;
self.selected_entry = None;
self.viewer = None;
self.editor = None;
self.mode = Mode::Browser;
self.focus = PaneFocus::Main;
self.status = message;
}
Err(error) => {
self.git_pending = false;
self.status = error;
self.editor_generation_pending = None;
self.command_open_target = None;
@@ -588,7 +671,14 @@ impl App {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
if self.grep_view.is_some() && self.mode == Mode::Browser {
if self.git_pending {
self.status = "Cancelling Git operation…".to_owned();
return AppEffect::CancelGit;
} else if self.git_view.is_some() && self.mode == Mode::Browser {
self.git_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Git status closed".to_owned();
} else if self.grep_view.is_some() && self.mode == Mode::Browser {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Decrypted search results closed".to_owned();
@@ -833,7 +923,18 @@ impl App {
return AppEffect::None;
}
self.status = format!("Selected {} workflow", workflow_label(workflow));
return AppEffect::OpenWorkflow(workflow);
self.git_pending = true;
return AppEffect::AuthenticateGit(match workflow {
WorkflowAction::GitPull => ironstorage::command::GitRequest::Pull {
remote: None,
branch: None,
},
WorkflowAction::GitPush => ironstorage::command::GitRequest::Push {
remote: None,
branch: None,
},
_ => unreachable!("only Git workflows reach this branch"),
});
}
Action::Quit => {}
}
@@ -959,6 +1060,29 @@ impl App {
self.should_quit = true;
AppEffect::None
}
CommandInvocation::ResolveGit(choice) => {
let Some(view) = self.git_view.as_ref() else {
self.status = "There is no active Git conflict set".to_owned();
return AppEffect::None;
};
if view.conflicts.is_empty() {
self.status = "The active Git snapshot has no conflicts".to_owned();
return AppEffect::None;
}
let resolutions = view
.conflicts
.iter()
.map(|conflict| {
ironstorage::git::GitConflictResolution::new(
conflict.path().to_owned(),
choice,
)
})
.collect();
self.git_pending = true;
self.status = format!("Resolving all conflicts with {choice:?} versions…");
AppEffect::ResolveGit(resolutions)
}
CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
self.status = "Command is unavailable in the current mode".to_owned();
AppEffect::None
@@ -1371,6 +1495,7 @@ impl App {
}
pub fn authentication_failed(&mut self, message: String) {
self.git_pending = false;
self.authentication_pending = None;
self.selected_entry = None;
self.viewer = None;
@@ -1397,6 +1522,8 @@ impl App {
let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
self.transition(Transition::Lock);
self.authentication_pending = None;
self.git_pending = false;
self.git_view = None;
self.remaining_lease = None;
self.status = if discarded_edit {
format!("Locked: {reason}; unsaved edits were discarded")
@@ -1496,6 +1623,17 @@ impl App {
}
}
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
GitProgressPhase::Refreshing => "refreshing synchronized snapshot",
}
}
fn workflow_label(workflow: WorkflowAction) -> &'static str {
match workflow {
WorkflowAction::Initialize => "initialization",

View File

@@ -11,8 +11,20 @@ const ROOT_COMMANDS: &[&str] = &[
"git", "otp", "lock", "unlock", "help", "version", "quit",
];
const GIT_COMMANDS: &[&str] = &[
"init", "status", "log", "diff", "add", "commit", "remote", "config", "fetch", "pull", "push",
"init",
"status",
"log",
"diff",
"add",
"commit",
"remote",
"config",
"fetch",
"pull",
"push",
"sync",
"resolve-local",
"resolve-remote",
];
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
@@ -55,6 +67,8 @@ pub const COMMAND_COVERAGE: &[CommandCoverage] = &[
coverage("pull Git remote", ":git pull [REMOTE] [BRANCH]"),
coverage("push Git remote", ":git push [REMOTE] [BRANCH]"),
coverage("synchronize Git remote", ":git sync [REMOTE]"),
coverage("resolve Git conflicts locally", ":git resolve-local"),
coverage("resolve Git conflicts remotely", ":git resolve-remote"),
coverage("generate an OTP code", ":otp code [OPTIONS] ENTRY"),
coverage("insert an OTP entry", ":otp insert [OPTIONS] [ENTRY]"),
coverage("append OTP data", ":otp append [OPTIONS] ENTRY"),
@@ -84,6 +98,7 @@ pub enum CommandInvocation {
Lock,
Unlock,
Quit,
ResolveGit(ironstorage::git::GitConflictChoice),
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -334,10 +349,26 @@ pub fn parse_command(input: &str) -> Result<CommandInvocation, CommandError> {
let Some(command) = tokens.first().map(String::as_str) else {
return Err(CommandError::new("command is empty"));
};
if tokens.as_slice() == ["git", "resolve-local"] {
return Ok(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Local,
));
}
if tokens.as_slice() == ["git", "resolve-remote"] {
return Ok(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Remote,
));
}
let builtin = match command {
"lock" => Some(CommandInvocation::Lock),
"unlock" => Some(CommandInvocation::Unlock),
"quit" | "q" => Some(CommandInvocation::Quit),
"git-resolve-local" => Some(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Local,
)),
"git-resolve-remote" => Some(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Remote,
)),
_ => None,
};
if let Some(invocation) = builtin {
@@ -758,6 +789,18 @@ mod tests {
#[test]
fn destructive_and_nested_requests_preserve_typed_storage_contracts() {
assert_eq!(
parse_command("git resolve-local"),
Ok(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Local
))
);
assert_eq!(
parse_command("git resolve-remote"),
Ok(CommandInvocation::ResolveGit(
ironstorage::git::GitConflictChoice::Remote
))
);
assert_eq!(
parse_command("remove -r folder"),
Ok(CommandInvocation::Storage(CommandRequest::Remove(

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

View File

@@ -35,6 +35,7 @@ pub enum AuthenticationEvent {
pub enum AuthenticationTarget {
Entry(String),
Workflow(Box<WorkflowSubmission>),
Git(ironstorage::command::GitRequest),
}
struct AuthenticationCompletion {
@@ -79,6 +80,10 @@ impl AuthenticationCoordinator {
self.request(AuthenticationTarget::Workflow(submission));
}
pub fn request_git(&mut self, request: ironstorage::command::GitRequest) {
self.request(AuthenticationTarget::Git(request));
}
fn request(&mut self, target: AuthenticationTarget) {
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
@@ -199,6 +204,19 @@ impl AsyncExecutor {
.push(task);
}
pub fn progress_reporter(
&self,
token: RequestToken,
) -> impl Fn(ironstorage::git::GitProgressPhase) + Send + Sync + 'static {
let sender = self.sender.clone();
move |phase| {
let _ignored = sender.send(AsyncResult {
token,
payload: Ok(AsyncPayload::GitProgress(phase)),
});
}
}
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
self.receiver.try_iter()
}

View File

@@ -170,6 +170,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
))
},
),
Mode::Browser if app.git_view().is_some() => {
Paragraph::new(git_lines(app.git_view().expect("checked Git view")))
}
Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
app.grep_view().expect("checked decrypted search results"),
)),
@@ -434,6 +437,88 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
lines
}
fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
let snapshot = view.snapshot();
let status = snapshot.status();
let mut lines = vec![
Line::styled(
view.message(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Line::raw(format!("repository: {}", snapshot.root().display())),
Line::raw(format!("branch: {}", snapshot.branch())),
Line::raw(format!(
"worktree: {}",
if status.is_clean() {
"clean".to_owned()
} else {
format!(
"{} staged, {} unstaged",
status.staged().len(),
status.unstaged().len()
)
}
)),
];
if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!(
"remote: {} {}",
remote.name(),
remote.url()
)));
lines.push(Line::raw(format!(
"relation: {} ahead, {} behind",
remote.ahead(),
remote.behind()
)));
} else {
lines.push(Line::styled(
"remote: not configured",
Style::default().fg(Color::Yellow),
));
}
if !view.conflicts().is_empty() {
lines.push(Line::styled(
"merge conflicts:",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
lines.extend(view.conflicts().iter().map(|conflict| {
Line::raw(format!(
" {:?}: {}",
conflict.kind(),
conflict.path().display()
))
}));
lines.push(Line::raw(
"Use :git resolve-local or :git resolve-remote for all listed paths.",
));
}
if let Some(details) = view.details() {
lines.push(Line::raw(""));
lines.extend(
String::from_utf8_lossy(details.expose())
.lines()
.map(|line| Line::raw(line.to_owned())),
);
} else if !snapshot.recent().is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
"recent commits:",
Style::default().add_modifier(Modifier::BOLD),
));
lines.extend(snapshot.recent().iter().map(|entry| {
Line::raw(format!(
" {} {}",
&entry.id()[..entry.id().len().min(12)],
entry.message()
))
}));
}
lines
}
fn mode_title(mode: Mode) -> &'static str {
match mode {
Mode::Browser => "Browser",