Implement embedded Git synchronization TUI
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user