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