diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 91805e9..da28a77 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -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, + details: Option, +} + +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), @@ -106,6 +130,14 @@ pub enum AsyncPayload { result: Result, }, WorkflowFinished(Result, String>), + GitProgress(GitProgressPhase), + GitFinished { + snapshot: Box, + tree: Option, + message: String, + conflicts: Vec, + details: Option, + }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -160,7 +192,9 @@ pub enum AppEffect { editor: Box, }, AuthenticateWorkflow(Box), - OpenWorkflow(WorkflowAction), + AuthenticateGit(ironstorage::command::GitRequest), + CancelGit, + ResolveGit(Vec), RunCommand(CommandRequest), ManualLock, } @@ -193,6 +227,8 @@ pub struct App { workflow: Option, workflow_pending: bool, grep_view: Option, + git_view: Option, + git_pending: bool, remaining_lease: Option, 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 { 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", diff --git a/apps/tui/src/command.rs b/apps/tui/src/command.rs index 0b290e3..0c5badf 100644 --- a/apps/tui/src/command.rs +++ b/apps/tui/src/command.rs @@ -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 { 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( diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index b2cd42f..bfeb388 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -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, executor: &AsyncExecutor, + git_control: &mut Option, 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, + control: &ironstorage::git::GitOperationControl, +) -> Result { + 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::() + .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::>(), + &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::>()) + .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 { + 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 { 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, 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); diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 770b403..90d8e35 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -35,6 +35,7 @@ pub enum AuthenticationEvent { pub enum AuthenticationTarget { Entry(String), Workflow(Box), + 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 + '_ { self.receiver.try_iter() } diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index d7e7b9c..870143a 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -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> { lines } +fn git_lines(view: &crate::app::GitView) -> Vec> { + 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", diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 4f90b39..7e82e5b 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -71,6 +71,18 @@ impl Config { &self.git_remotes } + /// Select a configured remote by name, or the configured default (first + /// remote) when no name was requested. + pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> { + match requested { + Some(name) => self + .git_remotes + .iter() + .find(|remote| remote.name().as_str() == name), + None => self.git_remotes.first(), + } + } + /// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`. pub fn resolve_editor(&self) -> Result { self.resolve_editor_from( diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index dd54129..ecbb884 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -9,6 +9,10 @@ use std::{ error::Error, fmt, fs, path::{Component, Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use flate2::{Compression, write::ZlibEncoder}; @@ -102,6 +106,153 @@ pub struct GitStatus { unstaged: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitConflictKind { + Content, + AddAdd, + ModifyDelete, + Structural, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitConflict { + path: PathBuf, + kind: GitConflictKind, +} + +impl GitConflict { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn kind(&self) -> GitConflictKind { + self.kind + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitConflictChoice { + Local, + Remote, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitConflictResolution { + path: PathBuf, + choice: GitConflictChoice, +} + +impl GitConflictResolution { + pub fn new(path: PathBuf, choice: GitConflictChoice) -> Self { + Self { path, choice } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitRemoteStatus { + name: String, + url: String, + ahead: usize, + behind: usize, +} + +impl GitRemoteStatus { + pub fn name(&self) -> &str { + &self.name + } + pub fn url(&self) -> &str { + &self.url + } + pub fn ahead(&self) -> usize { + self.ahead + } + pub fn behind(&self) -> usize { + self.behind + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitSnapshot { + root: PathBuf, + branch: String, + status: GitStatus, + remote: Option, + recent: Vec, +} + +impl GitSnapshot { + pub fn root(&self) -> &Path { + &self.root + } + pub fn branch(&self) -> &str { + &self.branch + } + pub fn status(&self) -> &GitStatus { + &self.status + } + pub fn remote(&self) -> Option<&GitRemoteStatus> { + self.remote.as_ref() + } + pub fn recent(&self) -> &[GitLogEntry] { + &self.recent + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitProgressPhase { + Validating, + Authenticating, + Receiving, + Integrating, + Sending, + Refreshing, +} + +#[derive(Clone)] +pub struct GitOperationControl { + cancelled: Arc, + progress: Arc, +} + +impl Default for GitOperationControl { + fn default() -> Self { + Self::new(|_| {}) + } +} + +impl GitOperationControl { + pub fn new(progress: impl Fn(GitProgressPhase) + Send + Sync + 'static) -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + progress: Arc::new(progress), + } + } + + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + + fn checkpoint(&self, phase: GitProgressPhase) -> Result<(), GitError> { + if self.is_cancelled() { + return Err(GitError::Cancelled); + } + (self.progress)(phase); + if self.is_cancelled() { + Err(GitError::Cancelled) + } else { + Ok(()) + } + } + + pub fn report(&self, phase: GitProgressPhase) -> Result<(), GitError> { + self.checkpoint(phase) + } +} + impl GitStatus { pub fn staged(&self) -> &[GitChange] { &self.staged @@ -193,9 +344,15 @@ pub enum GitError { CredentialAccessDenied, CredentialCancelled, AuthenticationFailed, + NetworkUnavailable, + TlsFailed, + Cancelled, NonFastForward, MergeConflicts { - paths: Vec, + conflicts: Vec, + }, + InvalidConflictResolution { + path: PathBuf, }, InvalidRepository(String), Io { @@ -238,11 +395,19 @@ impl fmt::Display for GitError { formatter.write_str("HTTPS Git credential authentication was cancelled") } Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"), + Self::NetworkUnavailable => formatter.write_str("the HTTPS Git remote is unavailable"), + Self::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"), + Self::Cancelled => formatter.write_str("the Git operation was cancelled"), Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"), - Self::MergeConflicts { paths } => write!( + Self::MergeConflicts { conflicts } => write!( formatter, "the merge has conflicts in {} path(s)", - paths.len() + conflicts.len() + ), + Self::InvalidConflictResolution { path } => write!( + formatter, + "no supported merge conflict exists at {}", + path.display() ), Self::InvalidRepository(message) => { write!(formatter, "invalid Git repository: {message}") @@ -408,6 +573,31 @@ pub trait GitSmartHttpTransport { credential: &GitCredential, request: Vec, ) -> Result, GitError>; + + fn advertise_receive_pack_controlled( + &self, + url: &url::Url, + credential: &GitCredential, + control: &GitOperationControl, + ) -> Result, GitError> { + control.checkpoint(GitProgressPhase::Receiving)?; + let result = self.advertise_receive_pack(url, credential)?; + control.checkpoint(GitProgressPhase::Receiving)?; + Ok(result) + } + + fn receive_pack_controlled( + &self, + url: &url::Url, + credential: &GitCredential, + request: Vec, + control: &GitOperationControl, + ) -> Result, GitError> { + control.checkpoint(GitProgressPhase::Sending)?; + let result = self.receive_pack(url, credential, request)?; + control.checkpoint(GitProgressPhase::Sending)?; + Ok(result) + } } pub trait GitFetchTransport { @@ -417,6 +607,19 @@ pub trait GitFetchTransport { configured: &GitRemote, credential: &GitCredential, ) -> Result; + + fn fetch_controlled( + &self, + repository: &GitRepository, + configured: &GitRemote, + credential: &GitCredential, + control: &GitOperationControl, + ) -> Result { + control.checkpoint(GitProgressPhase::Receiving)?; + let result = self.fetch(repository, configured, credential)?; + control.checkpoint(GitProgressPhase::Receiving)?; + Ok(result) + } } #[derive(Default)] @@ -431,6 +634,16 @@ impl GitFetchTransport for EmbeddedFetchTransport { ) -> Result { repository.fetch_embedded(configured, credential) } + + fn fetch_controlled( + &self, + repository: &GitRepository, + configured: &GitRemote, + credential: &GitCredential, + control: &GitOperationControl, + ) -> Result { + repository.fetch_embedded_controlled(configured, credential, control) + } } #[derive(Default)] @@ -473,7 +686,7 @@ impl ReqwestGitTransport { response .bytes() .map(|bytes| bytes.to_vec()) - .map_err(invalid) + .map_err(map_reqwest_error) } } @@ -493,7 +706,7 @@ impl GitSmartHttpTransport for ReqwestGitTransport { Self::response( Self::authenticated(request, credential)? .send() - .map_err(invalid)?, + .map_err(map_reqwest_error)?, ) } @@ -512,11 +725,23 @@ impl GitSmartHttpTransport for ReqwestGitTransport { Self::response( Self::authenticated(request, credential)? .send() - .map_err(invalid)?, + .map_err(map_reqwest_error)?, ) } } +fn map_reqwest_error(error: reqwest::Error) -> GitError { + if error.is_builder() { + return invalid(error); + } + let message = error.to_string().to_ascii_lowercase(); + if message.contains("certificate") || message.contains("tls") { + GitError::TlsFailed + } else { + GitError::NetworkUnavailable + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct FetchOutcome { remote: String, @@ -877,14 +1102,31 @@ impl GitRepository { credentials: &impl GitCredentialProvider, transport: &impl GitFetchTransport, ) -> Result { + self.fetch_with_transport_controlled( + configured, + credentials, + transport, + &GitOperationControl::default(), + ) + } + + pub fn fetch_with_transport_controlled( + &self, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + transport: &impl GitFetchTransport, + control: &GitOperationControl, + ) -> Result { + control.checkpoint(GitProgressPhase::Validating)?; let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; if actual_url != configured.url().as_str() { return Err(GitError::ForbiddenRemoteUrl); } + control.checkpoint(GitProgressPhase::Authenticating)?; let credential = credentials.credential(configured.server_id(), configured.application_id())?; - let received_pack = transport.fetch(self, configured, &credential)?; + let received_pack = transport.fetch_controlled(self, configured, &credential, control)?; Ok(FetchOutcome { remote: name.to_owned(), received_pack, @@ -899,6 +1141,19 @@ impl GitRepository { &self, configured: &GitRemote, credential: &GitCredential, + ) -> Result { + self.fetch_embedded_controlled(configured, credential, &GitOperationControl::default()) + } + + #[allow( + clippy::result_large_err, + reason = "the gix credential callback fixes its protocol error type" + )] + fn fetch_embedded_controlled( + &self, + configured: &GitRemote, + credential: &GitCredential, + control: &GitOperationControl, ) -> Result { let name = configured.name().as_str(); let password = std::str::from_utf8(credential.password()) @@ -940,17 +1195,26 @@ impl GitRepository { .prepare_fetch(gix::progress::Discard, Default::default()) .map_err(invalid)?; let outcome = prepared - .receive( - gix::progress::Discard, - &std::sync::atomic::AtomicBool::new(false), - ) + .receive(gix::progress::Discard, control.cancelled.as_ref()) .map_err(|error| { + if control.is_cancelled() { + return GitError::Cancelled; + } let text = error.to_string(); if text.contains("401") || text.contains("403") || text.to_ascii_lowercase().contains("authentication") { GitError::AuthenticationFailed + } else if text.to_ascii_lowercase().contains("certificate") + || text.to_ascii_lowercase().contains("tls") + { + GitError::TlsFailed + } else if text.to_ascii_lowercase().contains("network") + || text.to_ascii_lowercase().contains("connect") + || text.to_ascii_lowercase().contains("dns") + { + GitError::NetworkUnavailable } else { invalid(error) } @@ -976,11 +1240,29 @@ impl GitRepository { branch: Option<&str>, credentials: &impl GitCredentialProvider, transport: &impl GitFetchTransport, + ) -> Result { + self.pull_with_transport_controlled( + configured, + branch, + credentials, + transport, + &GitOperationControl::default(), + ) + } + + pub fn pull_with_transport_controlled( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + transport: &impl GitFetchTransport, + control: &GitOperationControl, ) -> Result { if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } - self.fetch_with_transport(configured, credentials, transport)?; + self.fetch_with_transport_controlled(configured, credentials, transport, control)?; + control.checkpoint(GitProgressPhase::Integrating)?; self.integrate_fetched(configured, branch) } @@ -1049,16 +1331,16 @@ impl GitRepository { .map_err(invalid)?; let unresolved = gix::merge::tree::TreatAsUnresolved::default(); if outcome.tree_merge.has_unresolved_conflicts(unresolved) { - let mut paths = outcome + let mut conflicts = outcome .tree_merge .conflicts .iter() .filter(|conflict| conflict.is_unresolved(unresolved)) - .map(|conflict| PathBuf::from(conflict.ours.location().to_str_lossy().as_ref())) + .map(conflict_description) .collect::>(); - paths.sort(); - paths.dedup(); - return Err(GitError::MergeConflicts { paths }); + conflicts.sort_by(|left, right| left.path.cmp(&right.path)); + conflicts.dedup_by(|left, right| left.path == right.path); + return Err(GitError::MergeConflicts { conflicts }); } let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); let signature = self.identity.signature(); @@ -1098,6 +1380,24 @@ impl GitRepository { credentials: &impl GitCredentialProvider, transport: &impl GitSmartHttpTransport, ) -> Result { + self.push_with_transport_controlled( + configured, + branch, + credentials, + transport, + &GitOperationControl::default(), + ) + } + + pub fn push_with_transport_controlled( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + transport: &impl GitSmartHttpTransport, + control: &GitOperationControl, + ) -> Result { + control.checkpoint(GitProgressPhase::Validating)?; if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } @@ -1120,9 +1420,11 @@ impl GitRepository { .head_id() .map_err(|_| GitError::UnbornHead)? .detach(); + control.checkpoint(GitProgressPhase::Authenticating)?; let credential = credentials.credential(configured.server_id(), configured.application_id())?; - let advertisement = transport.advertise_receive_pack(&url, &credential)?; + let advertisement = + transport.advertise_receive_pack_controlled(&url, &credential, control)?; let advertised = parse_receive_pack_advertisement(&advertisement)?; let old = advertised.refs.get(&reference).copied(); if let Some(old) = old { @@ -1160,8 +1462,9 @@ impl GitRepository { let mut request = encode_pkt_line(command.as_bytes())?; request.extend_from_slice(b"0000"); request.extend_from_slice(&pack); - let response = transport.receive_pack(&url, &credential, request)?; + let response = transport.receive_pack_controlled(&url, &credential, request, control)?; parse_receive_pack_result(&response, &reference)?; + self.update_remote_tracking(name, &branch, new)?; Ok(PushOutcome { remote: name.to_owned(), branch, @@ -1180,7 +1483,243 @@ impl GitRepository { Ok((pull, push)) } - fn current_branch(&self) -> Result { + /// Return one internally consistent, secret-free view of repository state + /// for frontends. The remote relation uses only the last fetched tracking + /// ref and therefore never performs network access. + pub fn snapshot( + &self, + configured: Option<&GitRemote>, + recent_limit: usize, + ) -> Result { + let branch = self.current_branch()?; + let status = self.status()?; + let recent = match self.log(Some(recent_limit)) { + Ok(entries) => entries, + Err(GitError::UnbornHead) => Vec::new(), + Err(error) => return Err(error), + }; + let remote = configured + .map(|configured| { + let name = configured.name().as_str(); + let actual_url = if self.remotes().iter().any(|remote| remote == name) { + let actual = self.remote_url(name)?; + if actual != configured.url().as_str() { + return Err(GitError::ForbiddenRemoteUrl); + } + actual + } else { + configured.url().to_string() + }; + let remote_ref = format!("refs/remotes/{name}/{branch}"); + let relation = self + .repository + .find_reference(&remote_ref) + .ok() + .and_then(|reference| reference.into_fully_peeled_id().ok()) + .and_then(|remote_id| { + self.repository + .head_id() + .ok() + .map(|local_id| (local_id.detach(), remote_id.detach())) + }); + let (ahead, behind) = relation.map_or(Ok((0, 0)), |(local, remote)| { + self.ahead_behind(local, remote) + })?; + Ok(GitRemoteStatus { + name: name.to_owned(), + url: actual_url, + ahead, + behind, + }) + }) + .transpose()?; + Ok(GitSnapshot { + root: self.root.clone(), + branch, + status, + remote, + recent, + }) + } + + fn ahead_behind( + &self, + local: gix::hash::ObjectId, + remote: gix::hash::ObjectId, + ) -> Result<(usize, usize), GitError> { + let ancestors = |id| -> Result, GitError> { + let commit = self + .repository + .find_object(id) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)?; + let mut ids = BTreeSet::from([id]); + for info in commit.ancestors().all().map_err(invalid)? { + ids.insert(info.map_err(invalid)?.id); + } + Ok(ids) + }; + let local_ids = ancestors(local)?; + let remote_ids = ancestors(remote)?; + Ok(( + local_ids.difference(&remote_ids).count(), + remote_ids.difference(&local_ids).count(), + )) + } + + fn update_remote_tracking( + &self, + remote: &str, + branch: &str, + id: gix::hash::ObjectId, + ) -> Result<(), GitError> { + use gix::refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }; + let name = gix::refs::FullName::try_from(format!("refs/remotes/{remote}/{branch}")) + .map_err(invalid)?; + let edit = RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "push: update remote-tracking branch".into(), + }, + expected: PreviousValue::Any, + new: Target::Object(id), + }, + name, + deref: false, + }; + let signature = self.identity.signature(); + let mut time = gix::date::parse::TimeBuf::default(); + self.repository + .edit_references_as(Some(edit), Some(signature.to_ref(&mut time))) + .map_err(invalid)?; + Ok(()) + } + + /// Resolve every conflict in the current fetched merge using explicit + /// whole-path local/remote choices. No repository state is changed unless + /// all choices are valid and the merge commit and checkout both succeed. + pub fn resolve_fetched( + &self, + configured: &GitRemote, + branch: Option<&str>, + resolutions: &[GitConflictResolution], + ) -> Result { + if !self.status()?.is_clean() { + return Err(GitError::DirtyWorktree); + } + let branch = branch.map_or_else( + || self.current_branch(), + |branch| { + validate_remote_name(branch)?; + Ok(branch.to_owned()) + }, + )?; + let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name()); + let remote_id = self + .repository + .find_reference(&remote_ref_name) + .map_err(|_| GitError::RemoteNotFound { + name: remote_ref_name.clone(), + })? + .into_fully_peeled_id() + .map_err(invalid)? + .detach(); + let local_id = self + .repository + .head_id() + .map_err(|_| GitError::UnbornHead)? + .detach(); + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: Some("base".into()), + current: Some("HEAD".into()), + other: Some(configured.name().as_str().into()), + }; + let options = self + .repository + .tree_merge_options() + .map_err(invalid)? + .into(); + let mut outcome = self + .repository + .merge_commits(local_id, remote_id, labels, options) + .map_err(invalid)?; + let unresolved = gix::merge::tree::TreatAsUnresolved::default(); + let choices = resolutions + .iter() + .map(|resolution| (resolution.path.clone(), resolution.choice)) + .collect::>(); + let conflicts = outcome + .tree_merge + .conflicts + .iter() + .filter(|conflict| conflict.is_unresolved(unresolved)) + .collect::>(); + for conflict in &conflicts { + let description = conflict_description(conflict); + let choice = choices.get(&description.path).ok_or_else(|| { + GitError::InvalidConflictResolution { + path: description.path.clone(), + } + })?; + let entries = conflict.entries(); + let selected = match choice { + GitConflictChoice::Local => entries[1], + GitConflictChoice::Remote => entries[2], + }; + let path = path_to_git(&description.path)?; + let _ = outcome.tree_merge.tree.remove(path.as_bstr()); + if let Some(entry) = selected { + outcome + .tree_merge + .tree + .upsert(path.as_bstr(), entry.mode.kind(), entry.id) + .map_err(invalid)?; + } + } + if choices.len() != conflicts.len() { + let extra = choices + .keys() + .find(|path| { + !conflicts + .iter() + .any(|conflict| conflict_description(conflict).path == **path) + }) + .cloned() + .unwrap_or_default(); + return Err(GitError::InvalidConflictResolution { path: extra }); + } + if conflicts.is_empty() { + return self.integrate_fetched(configured, Some(&branch)); + } + let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); + let signature = self.identity.signature(); + let mut committer_time = gix::date::parse::TimeBuf::default(); + let mut author_time = gix::date::parse::TimeBuf::default(); + let commit = self + .repository + .new_commit_as( + signature.to_ref(&mut committer_time), + signature.to_ref(&mut author_time), + format!( + "Merge remote-tracking branch '{}/{}'.", + configured.name(), + branch + ), + tree, + [local_id, remote_id], + ) + .map_err(invalid)?; + self.checkout_and_update(commit.id, Some(local_id))?; + Ok(PullOutcome::Merged) + } + + pub fn current_branch(&self) -> Result { let name = self .repository .head_name() @@ -1782,6 +2321,30 @@ fn diff_contents( } } +fn conflict_description(conflict: &gix::merge::tree::Conflict) -> GitConflict { + let entries = conflict.entries(); + let kind = match ( + entries[0].is_some(), + entries[1].is_some(), + entries[2].is_some(), + ) { + (false, true, true) => GitConflictKind::AddAdd, + (true, false, true) | (true, true, false) => GitConflictKind::ModifyDelete, + (true, true, true) if conflict.content_merge().is_some() => GitConflictKind::Content, + _ => GitConflictKind::Structural, + }; + let (ours, theirs) = conflict.changes_in_resolution(); + let location = if ours.location().is_empty() { + theirs.location() + } else { + ours.location() + }; + GitConflict { + path: PathBuf::from(location.to_str_lossy().as_ref()), + kind, + } +} + fn append_diff_lines(output: &mut Vec, prefix: u8, contents: &[u8]) { if contents.is_empty() { return; diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 79d0f0a..b9c3822 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -2,15 +2,22 @@ mod support; -use std::{error::Error, fs, io::Cursor, path::Path, sync::Mutex}; +use std::{ + error::Error, + fs, + io::Cursor, + path::Path, + sync::{Arc, Mutex}, +}; use ironstorage::{ command::{InsertInput, InsertRequest}, config::{Config, ConfigLoader, GitRemote}, crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, git::{ - GitChangeKind, GitCredential, GitCredentialProvider, GitError, GitFetchTransport, - GitIdentity, GitRepository, GitSmartHttpTransport, PullOutcome, + GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential, + GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl, + GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome, }, repository::{Repository, SecretBytes}, write::{InsertContent, OverwriteDecision, VaultWriter}, @@ -206,6 +213,19 @@ impl GitFetchTransport for NoopFetch { } } +struct FailedFetch(GitError); + +impl GitFetchTransport for FailedFetch { + fn fetch( + &self, + _repository: &GitRepository, + _configured: &GitRemote, + _credential: &GitCredential, + ) -> Result { + Err(self.0.clone()) + } +} + struct AuthenticationFailure; impl GitSmartHttpTransport for AuthenticationFailure { @@ -371,13 +391,95 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult { let error = git .pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch) .expect_err("conflicting histories"); + let GitError::MergeConflicts { conflicts } = error else { + panic!("expected typed merge conflicts"); + }; + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].path(), Path::new("secret.gpg")); assert_eq!( - error, - GitError::MergeConflicts { - paths: vec!["secret.gpg".into()] - } + conflicts[0].kind(), + ironstorage::git::GitConflictKind::Content ); assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"local"); + let snapshot = git.snapshot(Some(remote), 5)?; + assert_eq!(snapshot.remote().expect("remote status").ahead(), 1); + assert_eq!(snapshot.remote().expect("remote status").behind(), 1); + + assert_eq!( + git.resolve_fetched( + remote, + Some("main"), + &[GitConflictResolution::new( + "secret.gpg".into(), + GitConflictChoice::Remote, + )], + )?, + PullOutcome::Merged + ); + assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"remote"); + assert_eq!(git.log(Some(1))?[0].parents().len(), 2); + Ok(()) +} + +#[test] +fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult { + let temporary = tempfile::tempdir()?; + let config = remote_config(&temporary)?; + let remote = &config.git_remotes()[0]; + let store = Repository::open(config.vault())?; + let mut git = GitRepository::init(&store, identity())?; + git.add_remote("origin", remote.url().as_str())?; + let control = GitOperationControl::default(); + control.cancel(); + assert_eq!( + git.pull_with_transport_controlled( + remote, + Some("main"), + &Credentials, + &NoopFetch, + &control, + ), + Err(GitError::Cancelled) + ); + assert!(git.status()?.is_clean()); + let phases = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&phases); + let progress = GitOperationControl::new(move |phase| { + recorded.lock().expect("progress lock").push(phase); + }); + assert!(matches!( + git.pull_with_transport_controlled( + remote, + Some("main"), + &Credentials, + &NoopFetch, + &progress, + ), + Err(GitError::RemoteNotFound { .. }) + )); + assert_eq!( + *phases.lock().expect("progress lock"), + [ + GitProgressPhase::Validating, + GitProgressPhase::Authenticating, + GitProgressPhase::Receiving, + GitProgressPhase::Receiving, + GitProgressPhase::Integrating, + ] + ); + for expected in [GitError::NetworkUnavailable, GitError::TlsFailed] { + assert_eq!( + git.pull_with_transport( + remote, + Some("main"), + &Credentials, + &FailedFetch(expected.clone()), + ), + Err(expected) + ); + } + let credential = GitCredential::new("alice", b"DO-NOT-RENDER".to_vec())?; + assert_eq!(format!("{credential:?}"), "GitCredential([REDACTED])"); Ok(()) }