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, config::Config,
crypto::KeyInfo, crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId}, document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
presentation::ClipboardDisposition, presentation::ClipboardDisposition,
read::{FindResults, GrepResults, TreeModel}, read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes, repository::SecretBytes,
@@ -79,6 +80,29 @@ pub struct WorkflowSuccess {
pub status: String, 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)] #[derive(Debug)]
pub enum AsyncPayload { pub enum AsyncPayload {
Startup(Box<StartupData>), Startup(Box<StartupData>),
@@ -106,6 +130,14 @@ pub enum AsyncPayload {
result: Result<WriteOutcome, EditorSaveFailure>, result: Result<WriteOutcome, EditorSaveFailure>,
}, },
WorkflowFinished(Result<Box<WorkflowSuccess>, String>), 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -160,7 +192,9 @@ pub enum AppEffect {
editor: Box<EntryEditor>, editor: Box<EntryEditor>,
}, },
AuthenticateWorkflow(Box<WorkflowSubmission>), AuthenticateWorkflow(Box<WorkflowSubmission>),
OpenWorkflow(WorkflowAction), AuthenticateGit(ironstorage::command::GitRequest),
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
RunCommand(CommandRequest), RunCommand(CommandRequest),
ManualLock, ManualLock,
} }
@@ -193,6 +227,8 @@ pub struct App {
workflow: Option<WorkflowForm>, workflow: Option<WorkflowForm>,
workflow_pending: bool, workflow_pending: bool,
grep_view: Option<GrepView>, grep_view: Option<GrepView>,
git_view: Option<GitView>,
git_pending: bool,
remaining_lease: Option<std::time::Duration>, remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16), terminal_size: (u16, u16),
ticks: u64, ticks: u64,
@@ -231,6 +267,8 @@ impl App {
workflow: None, workflow: None,
workflow_pending: false, workflow_pending: false,
grep_view: None, grep_view: None,
git_view: None,
git_pending: false,
remaining_lease: None, remaining_lease: None,
terminal_size: (0, 0), terminal_size: (0, 0),
ticks: 0, ticks: 0,
@@ -321,6 +359,19 @@ impl App {
self.grep_view.as_ref() 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> { pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease self.remaining_lease
} }
@@ -373,9 +424,12 @@ impl App {
} }
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition { 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; return ResultDisposition::Stale;
} }
if !matches!(result.payload, Ok(AsyncPayload::GitProgress(_))) {
self.pending.remove(&result.token.id);
}
match result.payload { match result.payload {
Ok(AsyncPayload::Startup(startup)) => { Ok(AsyncPayload::Startup(startup)) => {
self.status = format!("Vault: {}", startup.config.vault().display()); 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) => { Err(error) => {
self.git_pending = false;
self.status = error; self.status = error;
self.editor_generation_pending = None; self.editor_generation_pending = None;
self.command_open_target = None; self.command_open_target = None;
@@ -588,7 +671,14 @@ impl App {
self.transition(Transition::OpenCommand); self.transition(Transition::OpenCommand);
} }
Action::Cancel => { 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.grep_view = None;
self.focus = PaneFocus::Sidebar; self.focus = PaneFocus::Sidebar;
self.status = "Decrypted search results closed".to_owned(); self.status = "Decrypted search results closed".to_owned();
@@ -833,7 +923,18 @@ impl App {
return AppEffect::None; return AppEffect::None;
} }
self.status = format!("Selected {} workflow", workflow_label(workflow)); 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 => {} Action::Quit => {}
} }
@@ -959,6 +1060,29 @@ impl App {
self.should_quit = true; self.should_quit = true;
AppEffect::None 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 => { CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
self.status = "Command is unavailable in the current mode".to_owned(); self.status = "Command is unavailable in the current mode".to_owned();
AppEffect::None AppEffect::None
@@ -1371,6 +1495,7 @@ impl App {
} }
pub fn authentication_failed(&mut self, message: String) { pub fn authentication_failed(&mut self, message: String) {
self.git_pending = false;
self.authentication_pending = None; self.authentication_pending = None;
self.selected_entry = None; self.selected_entry = None;
self.viewer = None; self.viewer = None;
@@ -1397,6 +1522,8 @@ impl App {
let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor); let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
self.transition(Transition::Lock); self.transition(Transition::Lock);
self.authentication_pending = None; self.authentication_pending = None;
self.git_pending = false;
self.git_view = None;
self.remaining_lease = None; self.remaining_lease = None;
self.status = if discarded_edit { self.status = if discarded_edit {
format!("Locked: {reason}; unsaved edits were discarded") 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 { fn workflow_label(workflow: WorkflowAction) -> &'static str {
match workflow { match workflow {
WorkflowAction::Initialize => "initialization", WorkflowAction::Initialize => "initialization",

View File

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

View File

@@ -17,6 +17,7 @@ pub mod workflow;
use std::{ use std::{
io, io,
path::PathBuf,
sync::mpsc::{self, Sender}, sync::mpsc::{self, Sender},
time::Duration, time::Duration,
}; };
@@ -62,6 +63,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let executor = AsyncExecutor::new(); let executor = AsyncExecutor::new();
let mut clipboard_cancellations = ClipboardCancellations::default(); let mut clipboard_cancellations = ClipboardCancellations::default();
let mut authentication = None; let mut authentication = None;
let mut git_control = None;
let mut authentication_initialized = false; let mut authentication_initialized = false;
let mut key_resolver = KeyResolver::default(); let mut key_resolver = KeyResolver::default();
let startup = app.begin_latest_request(); let startup = app.begin_latest_request();
@@ -73,6 +75,9 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
for result in executor.drain() { for result in executor.drain() {
app.apply_result(result); app.apply_result(result);
} }
if !app.git_pending() {
git_control = None;
}
if !authentication_initialized if !authentication_initialized
&& let (Some(config), Some(key)) = (app.config(), app.default_key()) && 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() if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.completion() && 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()?; let size = terminal.size()?;
@@ -95,7 +100,13 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
app.tick(); app.tick();
if let Some(coordinator) = authentication.as_mut() { if let Some(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() { 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()); 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() if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity() && 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) { if let Some(effect) = app.handle_command_input(key.code, key.modifiers) {
key_resolver.reset(); key_resolver.reset();
@@ -116,6 +133,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
effect, effect,
&mut authentication, &mut authentication,
&executor, &executor,
&mut git_control,
&mut clipboard_cancellations, &mut clipboard_cancellations,
); );
continue; continue;
@@ -127,6 +145,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
effect, effect,
&mut authentication, &mut authentication,
&executor, &executor,
&mut git_control,
&mut clipboard_cancellations, &mut clipboard_cancellations,
); );
continue; continue;
@@ -154,6 +173,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
effect, effect,
&mut authentication, &mut authentication,
&executor, &executor,
&mut git_control,
&mut clipboard_cancellations, &mut clipboard_cancellations,
); );
} }
@@ -184,6 +204,7 @@ fn apply_app_effect(
effect: AppEffect, effect: AppEffect,
authentication: &mut Option<AuthenticationCoordinator>, authentication: &mut Option<AuthenticationCoordinator>,
executor: &AsyncExecutor, executor: &AsyncExecutor,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations, clipboard_cancellations: &mut ClipboardCancellations,
) { ) {
match effect { 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) => { AppEffect::CopyFocused(value) => {
if let Some(config) = app.config().cloned() { if let Some(config) = app.config().cloned() {
let (cancel, cancellation) = mpsc::channel(); 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) => { AppEffect::RunCommand(request) => {
app.report_status(format!( app.report_status(format!(
"{} is not implemented by this terminal workflow yet", "{} is not implemented by this terminal workflow yet",
crate::command::operation_name(&request) 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) 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> { fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
let repository = ironstorage::repository::Repository::open(config.vault()) let repository = ironstorage::repository::Repository::open(config.vault())
@@ -323,6 +712,7 @@ fn apply_authentication_event(
app: &mut App, app: &mut App,
coordinator: &AuthenticationCoordinator, coordinator: &AuthenticationCoordinator,
executor: &AsyncExecutor, executor: &AsyncExecutor,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
event: AuthenticationEvent, event: AuthenticationEvent,
) { ) {
match event { 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 } => { AuthenticationEvent::Failed { workflow, message } => {
if workflow { if workflow {
app.workflow_authentication_failed(message); app.workflow_authentication_failed(message);

View File

@@ -35,6 +35,7 @@ pub enum AuthenticationEvent {
pub enum AuthenticationTarget { pub enum AuthenticationTarget {
Entry(String), Entry(String),
Workflow(Box<WorkflowSubmission>), Workflow(Box<WorkflowSubmission>),
Git(ironstorage::command::GitRequest),
} }
struct AuthenticationCompletion { struct AuthenticationCompletion {
@@ -79,6 +80,10 @@ impl AuthenticationCoordinator {
self.request(AuthenticationTarget::Workflow(submission)); 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) { fn request(&mut self, target: AuthenticationTarget) {
self.generation = self.generation.wrapping_add(1); self.generation = self.generation.wrapping_add(1);
let generation = self.generation; let generation = self.generation;
@@ -199,6 +204,19 @@ impl AsyncExecutor {
.push(task); .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> + '_ { pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
self.receiver.try_iter() 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( Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
app.grep_view().expect("checked decrypted search results"), app.grep_view().expect("checked decrypted search results"),
)), )),
@@ -434,6 +437,88 @@ fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
lines 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 { fn mode_title(mode: Mode) -> &'static str {
match mode { match mode {
Mode::Browser => "Browser", Mode::Browser => "Browser",

View File

@@ -71,6 +71,18 @@ impl Config {
&self.git_remotes &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`. /// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`.
pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> { pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> {
self.resolve_editor_from( self.resolve_editor_from(

View File

@@ -9,6 +9,10 @@ use std::{
error::Error, error::Error,
fmt, fs, fmt, fs,
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
}; };
use flate2::{Compression, write::ZlibEncoder}; use flate2::{Compression, write::ZlibEncoder};
@@ -102,6 +106,153 @@ pub struct GitStatus {
unstaged: Vec<GitChange>, unstaged: Vec<GitChange>,
} }
#[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<GitRemoteStatus>,
recent: Vec<GitLogEntry>,
}
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<AtomicBool>,
progress: Arc<dyn Fn(GitProgressPhase) + Send + Sync>,
}
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 { impl GitStatus {
pub fn staged(&self) -> &[GitChange] { pub fn staged(&self) -> &[GitChange] {
&self.staged &self.staged
@@ -193,9 +344,15 @@ pub enum GitError {
CredentialAccessDenied, CredentialAccessDenied,
CredentialCancelled, CredentialCancelled,
AuthenticationFailed, AuthenticationFailed,
NetworkUnavailable,
TlsFailed,
Cancelled,
NonFastForward, NonFastForward,
MergeConflicts { MergeConflicts {
paths: Vec<PathBuf>, conflicts: Vec<GitConflict>,
},
InvalidConflictResolution {
path: PathBuf,
}, },
InvalidRepository(String), InvalidRepository(String),
Io { Io {
@@ -238,11 +395,19 @@ impl fmt::Display for GitError {
formatter.write_str("HTTPS Git credential authentication was cancelled") formatter.write_str("HTTPS Git credential authentication was cancelled")
} }
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"), 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::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
Self::MergeConflicts { paths } => write!( Self::MergeConflicts { conflicts } => write!(
formatter, formatter,
"the merge has conflicts in {} path(s)", "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) => { Self::InvalidRepository(message) => {
write!(formatter, "invalid Git repository: {message}") write!(formatter, "invalid Git repository: {message}")
@@ -408,6 +573,31 @@ pub trait GitSmartHttpTransport {
credential: &GitCredential, credential: &GitCredential,
request: Vec<u8>, request: Vec<u8>,
) -> Result<Vec<u8>, GitError>; ) -> Result<Vec<u8>, GitError>;
fn advertise_receive_pack_controlled(
&self,
url: &url::Url,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<Vec<u8>, 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<u8>,
control: &GitOperationControl,
) -> Result<Vec<u8>, GitError> {
control.checkpoint(GitProgressPhase::Sending)?;
let result = self.receive_pack(url, credential, request)?;
control.checkpoint(GitProgressPhase::Sending)?;
Ok(result)
}
} }
pub trait GitFetchTransport { pub trait GitFetchTransport {
@@ -417,6 +607,19 @@ pub trait GitFetchTransport {
configured: &GitRemote, configured: &GitRemote,
credential: &GitCredential, credential: &GitCredential,
) -> Result<bool, GitError>; ) -> Result<bool, GitError>;
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<bool, GitError> {
control.checkpoint(GitProgressPhase::Receiving)?;
let result = self.fetch(repository, configured, credential)?;
control.checkpoint(GitProgressPhase::Receiving)?;
Ok(result)
}
} }
#[derive(Default)] #[derive(Default)]
@@ -431,6 +634,16 @@ impl GitFetchTransport for EmbeddedFetchTransport {
) -> Result<bool, GitError> { ) -> Result<bool, GitError> {
repository.fetch_embedded(configured, credential) repository.fetch_embedded(configured, credential)
} }
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<bool, GitError> {
repository.fetch_embedded_controlled(configured, credential, control)
}
} }
#[derive(Default)] #[derive(Default)]
@@ -473,7 +686,7 @@ impl ReqwestGitTransport {
response response
.bytes() .bytes()
.map(|bytes| bytes.to_vec()) .map(|bytes| bytes.to_vec())
.map_err(invalid) .map_err(map_reqwest_error)
} }
} }
@@ -493,7 +706,7 @@ impl GitSmartHttpTransport for ReqwestGitTransport {
Self::response( Self::response(
Self::authenticated(request, credential)? Self::authenticated(request, credential)?
.send() .send()
.map_err(invalid)?, .map_err(map_reqwest_error)?,
) )
} }
@@ -512,11 +725,23 @@ impl GitSmartHttpTransport for ReqwestGitTransport {
Self::response( Self::response(
Self::authenticated(request, credential)? Self::authenticated(request, credential)?
.send() .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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct FetchOutcome { pub struct FetchOutcome {
remote: String, remote: String,
@@ -877,14 +1102,31 @@ impl GitRepository {
credentials: &impl GitCredentialProvider, credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport, transport: &impl GitFetchTransport,
) -> Result<FetchOutcome, GitError> { ) -> Result<FetchOutcome, GitError> {
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<FetchOutcome, GitError> {
control.checkpoint(GitProgressPhase::Validating)?;
let name = configured.name().as_str(); let name = configured.name().as_str();
let actual_url = self.remote_url(name)?; let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() { if actual_url != configured.url().as_str() {
return Err(GitError::ForbiddenRemoteUrl); return Err(GitError::ForbiddenRemoteUrl);
} }
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = let credential =
credentials.credential(configured.server_id(), configured.application_id())?; 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 { Ok(FetchOutcome {
remote: name.to_owned(), remote: name.to_owned(),
received_pack, received_pack,
@@ -899,6 +1141,19 @@ impl GitRepository {
&self, &self,
configured: &GitRemote, configured: &GitRemote,
credential: &GitCredential, credential: &GitCredential,
) -> Result<bool, GitError> {
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<bool, GitError> { ) -> Result<bool, GitError> {
let name = configured.name().as_str(); let name = configured.name().as_str();
let password = std::str::from_utf8(credential.password()) let password = std::str::from_utf8(credential.password())
@@ -940,17 +1195,26 @@ impl GitRepository {
.prepare_fetch(gix::progress::Discard, Default::default()) .prepare_fetch(gix::progress::Discard, Default::default())
.map_err(invalid)?; .map_err(invalid)?;
let outcome = prepared let outcome = prepared
.receive( .receive(gix::progress::Discard, control.cancelled.as_ref())
gix::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
)
.map_err(|error| { .map_err(|error| {
if control.is_cancelled() {
return GitError::Cancelled;
}
let text = error.to_string(); let text = error.to_string();
if text.contains("401") if text.contains("401")
|| text.contains("403") || text.contains("403")
|| text.to_ascii_lowercase().contains("authentication") || text.to_ascii_lowercase().contains("authentication")
{ {
GitError::AuthenticationFailed 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 { } else {
invalid(error) invalid(error)
} }
@@ -976,11 +1240,29 @@ impl GitRepository {
branch: Option<&str>, branch: Option<&str>,
credentials: &impl GitCredentialProvider, credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport, transport: &impl GitFetchTransport,
) -> Result<PullOutcome, GitError> {
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<PullOutcome, GitError> { ) -> Result<PullOutcome, GitError> {
if !self.status()?.is_clean() { if !self.status()?.is_clean() {
return Err(GitError::DirtyWorktree); 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) self.integrate_fetched(configured, branch)
} }
@@ -1049,16 +1331,16 @@ impl GitRepository {
.map_err(invalid)?; .map_err(invalid)?;
let unresolved = gix::merge::tree::TreatAsUnresolved::default(); let unresolved = gix::merge::tree::TreatAsUnresolved::default();
if outcome.tree_merge.has_unresolved_conflicts(unresolved) { if outcome.tree_merge.has_unresolved_conflicts(unresolved) {
let mut paths = outcome let mut conflicts = outcome
.tree_merge .tree_merge
.conflicts .conflicts
.iter() .iter()
.filter(|conflict| conflict.is_unresolved(unresolved)) .filter(|conflict| conflict.is_unresolved(unresolved))
.map(|conflict| PathBuf::from(conflict.ours.location().to_str_lossy().as_ref())) .map(conflict_description)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
paths.sort(); conflicts.sort_by(|left, right| left.path.cmp(&right.path));
paths.dedup(); conflicts.dedup_by(|left, right| left.path == right.path);
return Err(GitError::MergeConflicts { paths }); return Err(GitError::MergeConflicts { conflicts });
} }
let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach();
let signature = self.identity.signature(); let signature = self.identity.signature();
@@ -1098,6 +1380,24 @@ impl GitRepository {
credentials: &impl GitCredentialProvider, credentials: &impl GitCredentialProvider,
transport: &impl GitSmartHttpTransport, transport: &impl GitSmartHttpTransport,
) -> Result<PushOutcome, GitError> { ) -> Result<PushOutcome, GitError> {
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<PushOutcome, GitError> {
control.checkpoint(GitProgressPhase::Validating)?;
if !self.status()?.is_clean() { if !self.status()?.is_clean() {
return Err(GitError::DirtyWorktree); return Err(GitError::DirtyWorktree);
} }
@@ -1120,9 +1420,11 @@ impl GitRepository {
.head_id() .head_id()
.map_err(|_| GitError::UnbornHead)? .map_err(|_| GitError::UnbornHead)?
.detach(); .detach();
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = let credential =
credentials.credential(configured.server_id(), configured.application_id())?; 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 advertised = parse_receive_pack_advertisement(&advertisement)?;
let old = advertised.refs.get(&reference).copied(); let old = advertised.refs.get(&reference).copied();
if let Some(old) = old { if let Some(old) = old {
@@ -1160,8 +1462,9 @@ impl GitRepository {
let mut request = encode_pkt_line(command.as_bytes())?; let mut request = encode_pkt_line(command.as_bytes())?;
request.extend_from_slice(b"0000"); request.extend_from_slice(b"0000");
request.extend_from_slice(&pack); 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)?; parse_receive_pack_result(&response, &reference)?;
self.update_remote_tracking(name, &branch, new)?;
Ok(PushOutcome { Ok(PushOutcome {
remote: name.to_owned(), remote: name.to_owned(),
branch, branch,
@@ -1180,7 +1483,243 @@ impl GitRepository {
Ok((pull, push)) Ok((pull, push))
} }
fn current_branch(&self) -> Result<String, GitError> { /// 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<GitSnapshot, GitError> {
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<BTreeSet<gix::hash::ObjectId>, 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<PullOutcome, GitError> {
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::<BTreeMap<_, _>>();
let conflicts = outcome
.tree_merge
.conflicts
.iter()
.filter(|conflict| conflict.is_unresolved(unresolved))
.collect::<Vec<_>>();
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<String, GitError> {
let name = self let name = self
.repository .repository
.head_name() .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<u8>, prefix: u8, contents: &[u8]) { fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
if contents.is_empty() { if contents.is_empty() {
return; return;

View File

@@ -2,15 +2,22 @@
mod support; 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::{ use ironstorage::{
command::{InsertInput, InsertRequest}, command::{InsertInput, InsertRequest},
config::{Config, ConfigLoader, GitRemote}, config::{Config, ConfigLoader, GitRemote},
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{ git::{
GitChangeKind, GitCredential, GitCredentialProvider, GitError, GitFetchTransport, GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
GitIdentity, GitRepository, GitSmartHttpTransport, PullOutcome, GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome,
}, },
repository::{Repository, SecretBytes}, repository::{Repository, SecretBytes},
write::{InsertContent, OverwriteDecision, VaultWriter}, 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<bool, GitError> {
Err(self.0.clone())
}
}
struct AuthenticationFailure; struct AuthenticationFailure;
impl GitSmartHttpTransport for AuthenticationFailure { impl GitSmartHttpTransport for AuthenticationFailure {
@@ -371,13 +391,95 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let error = git let error = git
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch) .pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)
.expect_err("conflicting histories"); .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!( assert_eq!(
error, conflicts[0].kind(),
GitError::MergeConflicts { ironstorage::git::GitConflictKind::Content
paths: vec!["secret.gpg".into()]
}
); );
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"local"); 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(()) Ok(())
} }