mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-27 17:55:11 +03:00
integrate library into app
Note that small `commands` modules are now inlined for ease of use.
This commit is contained in:
parent
3b89ed50f9
commit
2dbdc6ea99
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1973,6 +1973,7 @@ dependencies = [
|
||||
"futures",
|
||||
"git2",
|
||||
"git2-hooks",
|
||||
"gitbutler",
|
||||
"gitbutler-git",
|
||||
"governor",
|
||||
"itertools 0.12.1",
|
||||
|
@ -84,6 +84,7 @@ walkdir = "2.5.0"
|
||||
zip = "0.6.5"
|
||||
tempfile = "3.10"
|
||||
gitbutler-git = { path = "../gitbutler-git" }
|
||||
gitbutler = { path = "../" }
|
||||
|
||||
[lints.clippy]
|
||||
all = "deny"
|
||||
|
@ -2,7 +2,7 @@ use std::{fmt, str, sync::Arc};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{projects::ProjectId, users::User};
|
||||
use gitbutler::{projects::ProjectId, users::User};
|
||||
|
||||
mod posthog;
|
||||
|
||||
|
@ -2,7 +2,8 @@ use std::{collections::HashMap, path};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{
|
||||
use crate::watcher;
|
||||
use gitbutler::{
|
||||
askpass::AskpassBroker,
|
||||
gb_repository, git,
|
||||
project_repository::{self, conflicts},
|
||||
@ -11,7 +12,6 @@ use crate::{
|
||||
sessions::{self, SessionId},
|
||||
users,
|
||||
virtual_branches::BranchId,
|
||||
watcher,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
@ -1,70 +1,10 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use crate::id::Id;
|
||||
|
||||
pub struct AskpassRequest {
|
||||
sender: oneshot::Sender<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AskpassBroker {
|
||||
pending_requests: Arc<Mutex<HashMap<Id<AskpassRequest>, AskpassRequest>>>,
|
||||
handle: AppHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct PromptEvent<C: Serialize + Clone> {
|
||||
id: Id<AskpassRequest>,
|
||||
prompt: String,
|
||||
context: C,
|
||||
}
|
||||
|
||||
impl AskpassBroker {
|
||||
pub fn init(handle: AppHandle) -> Self {
|
||||
Self {
|
||||
pending_requests: Arc::new(Mutex::new(HashMap::new())),
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_prompt<C: Serialize + Clone>(
|
||||
&self,
|
||||
prompt: String,
|
||||
context: C,
|
||||
) -> Option<String> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let id = Id::generate();
|
||||
let request = AskpassRequest { sender };
|
||||
self.pending_requests.lock().await.insert(id, request);
|
||||
self.handle
|
||||
.emit_all(
|
||||
"git_prompt",
|
||||
PromptEvent {
|
||||
id,
|
||||
prompt,
|
||||
context,
|
||||
},
|
||||
)
|
||||
.expect("failed to emit askpass event");
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn handle_response(&self, id: Id<AskpassRequest>, response: Option<String>) {
|
||||
let mut pending_requests = self.pending_requests.lock().await;
|
||||
if let Some(request) = pending_requests.remove(&id) {
|
||||
let _ = request.sender.send(response);
|
||||
} else {
|
||||
log::warn!("received response for unknown askpass request: {}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod commands {
|
||||
use super::{AppHandle, AskpassBroker, AskpassRequest, Id, Manager};
|
||||
use gitbutler::{
|
||||
askpass::{AskpassBroker, AskpassRequest},
|
||||
id::Id,
|
||||
};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[tracing::instrument(skip(handle, response))]
|
||||
pub async fn submit_prompt_response(
|
||||
|
@ -1,204 +0,0 @@
|
||||
use std::{collections::HashMap, path, sync};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::future::join_all;
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
users,
|
||||
virtual_branches::{
|
||||
Author, BaseBranch, RemoteBranchData, RemoteCommit, VirtualBranch, VirtualBranchCommit,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Proxy {
|
||||
cache_dir: path::PathBuf,
|
||||
|
||||
semaphores: sync::Arc<tokio::sync::Mutex<HashMap<url::Url, Semaphore>>>,
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub fn new(cache_dir: path::PathBuf) -> Self {
|
||||
Proxy {
|
||||
cache_dir,
|
||||
semaphores: sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_user(&self, user: users::User) -> users::User {
|
||||
match Url::parse(&user.picture) {
|
||||
Ok(picture) => users::User {
|
||||
picture: self.proxy(&picture).await.map_or_else(
|
||||
|error| {
|
||||
tracing::error!(?error, "failed to proxy user picture");
|
||||
user.picture.clone()
|
||||
},
|
||||
|url| url.to_string(),
|
||||
),
|
||||
..user
|
||||
},
|
||||
Err(_) => user,
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_virtual_branch_commit(
|
||||
&self,
|
||||
commit: VirtualBranchCommit,
|
||||
) -> VirtualBranchCommit {
|
||||
VirtualBranchCommit {
|
||||
author: self.proxy_author(commit.author).await,
|
||||
..commit
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_virtual_branch(&self, branch: VirtualBranch) -> VirtualBranch {
|
||||
VirtualBranch {
|
||||
commits: join_all(
|
||||
branch
|
||||
.commits
|
||||
.iter()
|
||||
.map(|commit| self.proxy_virtual_branch_commit(commit.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await,
|
||||
..branch
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_virtual_branches(&self, branches: Vec<VirtualBranch>) -> Vec<VirtualBranch> {
|
||||
join_all(
|
||||
branches
|
||||
.into_iter()
|
||||
.map(|branch| self.proxy_virtual_branch(branch))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn proxy_remote_branch_data(&self, branch: RemoteBranchData) -> RemoteBranchData {
|
||||
RemoteBranchData {
|
||||
commits: join_all(
|
||||
branch
|
||||
.commits
|
||||
.into_iter()
|
||||
.map(|commit| self.proxy_remote_commit(commit))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await,
|
||||
..branch
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_author(&self, author: Author) -> Author {
|
||||
Author {
|
||||
gravatar_url: self
|
||||
.proxy(&author.gravatar_url)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::error!(gravatar_url = %author.gravatar_url, ?error, "failed to proxy gravatar url");
|
||||
author.gravatar_url
|
||||
}),
|
||||
..author
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_remote_commit(&self, commit: RemoteCommit) -> RemoteCommit {
|
||||
RemoteCommit {
|
||||
author: self.proxy_author(commit.author).await,
|
||||
..commit
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_base_branch(&self, base_branch: BaseBranch) -> BaseBranch {
|
||||
BaseBranch {
|
||||
recent_commits: join_all(
|
||||
base_branch
|
||||
.clone()
|
||||
.recent_commits
|
||||
.into_iter()
|
||||
.map(|commit| self.proxy_remote_commit(commit))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await,
|
||||
upstream_commits: join_all(
|
||||
base_branch
|
||||
.clone()
|
||||
.upstream_commits
|
||||
.into_iter()
|
||||
.map(|commit| self.proxy_remote_commit(commit))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await,
|
||||
..base_branch.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// takes a url of a remote assets, downloads it into cache and returns a url that points to the cached file
|
||||
pub async fn proxy(&self, src: &Url) -> Result<Url> {
|
||||
#[cfg(unix)]
|
||||
if src.scheme() == "asset" {
|
||||
return Ok(src.clone());
|
||||
}
|
||||
|
||||
if src.scheme() == "https" && src.host_str() == Some("asset.localhost") {
|
||||
return Ok(src.clone());
|
||||
}
|
||||
|
||||
let hash = md5::compute(src.to_string());
|
||||
let path = path::Path::new(src.path());
|
||||
let ext = path
|
||||
.extension()
|
||||
.map_or("jpg", |ext| ext.to_str().unwrap_or("jpg"));
|
||||
let save_to = self.cache_dir.join(format!("{:X}.{}", hash, ext));
|
||||
|
||||
if save_to.exists() {
|
||||
return Ok(build_asset_url(&save_to.display().to_string()));
|
||||
}
|
||||
|
||||
// only one download per url at a time
|
||||
let mut semaphores = self.semaphores.lock().await;
|
||||
let r = semaphores
|
||||
.entry(src.clone())
|
||||
.or_insert_with(|| Semaphore::new(1));
|
||||
let _permit = r.acquire().await?;
|
||||
|
||||
if save_to.exists() {
|
||||
// check again, maybe url was downloaded
|
||||
return Ok(build_asset_url(&save_to.display().to_string()));
|
||||
}
|
||||
|
||||
tracing::debug!(url = %src, "downloading image");
|
||||
|
||||
let resp = reqwest::get(src.clone()).await?;
|
||||
if !resp.status().is_success() {
|
||||
tracing::error!(url = %src, status = %resp.status(), "failed to download image");
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download image {}: {}",
|
||||
src,
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
std::fs::create_dir_all(&self.cache_dir)?;
|
||||
std::fs::write(&save_to, bytes)?;
|
||||
|
||||
Ok(build_asset_url(&save_to.display().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn build_asset_url(path: &str) -> Url {
|
||||
Url::parse(&format!("asset://localhost/{}", urlencoding::encode(path))).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn build_asset_url(path: &str) -> Url {
|
||||
Url::parse(&format!(
|
||||
"https://asset.localhost/{}",
|
||||
urlencoding::encode(path)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
@ -4,12 +4,12 @@ use anyhow::Context;
|
||||
use tauri::Manager;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
app,
|
||||
use crate::{app, watcher};
|
||||
use gitbutler::{
|
||||
error::{Code, Error},
|
||||
gb_repository, git, project_repository, projects, reader,
|
||||
sessions::SessionId,
|
||||
users, watcher,
|
||||
users,
|
||||
};
|
||||
|
||||
impl From<app::Error> for Error {
|
||||
@ -71,13 +71,13 @@ pub async fn git_test_push(
|
||||
branch_name: &str,
|
||||
) -> Result<(), Error> {
|
||||
let app = handle.state::<app::App>();
|
||||
let helper = handle.state::<crate::git::credentials::Helper>();
|
||||
let helper = handle.state::<gitbutler::git::credentials::Helper>();
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
let askpass_broker = handle
|
||||
.state::<crate::askpass::AskpassBroker>()
|
||||
.state::<gitbutler::askpass::AskpassBroker>()
|
||||
.inner()
|
||||
.clone();
|
||||
app.git_test_push(
|
||||
@ -102,13 +102,13 @@ pub async fn git_test_fetch(
|
||||
action: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let app = handle.state::<app::App>();
|
||||
let helper = handle.state::<crate::git::credentials::Helper>();
|
||||
let helper = handle.state::<gitbutler::git::credentials::Helper>();
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
let askpass_broker = handle
|
||||
.state::<crate::askpass::AskpassBroker>()
|
||||
.state::<gitbutler::askpass::AskpassBroker>()
|
||||
.inner()
|
||||
.clone();
|
||||
app.git_test_fetch(
|
||||
|
@ -1,48 +0,0 @@
|
||||
use std::{path, sync::Arc};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use refinery::config::Config;
|
||||
use rusqlite::Transaction;
|
||||
|
||||
mod embedded {
|
||||
use refinery::embed_migrations;
|
||||
embed_migrations!("src/database/migrations");
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: Arc<Pool<SqliteConnectionManager>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open_in_directory<P: AsRef<path::Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf().join("database.sqlite3");
|
||||
let manager = SqliteConnectionManager::file(&path);
|
||||
let pool = r2d2::Pool::new(manager)?;
|
||||
let mut cfg = Config::new(refinery::config::ConfigDbType::Sqlite)
|
||||
.set_db_path(path.as_path().to_str().unwrap());
|
||||
embedded::migrations::runner()
|
||||
.run(&mut cfg)
|
||||
.map(|report| {
|
||||
report
|
||||
.applied_migrations()
|
||||
.iter()
|
||||
.for_each(|migration| tracing::info!(%migration, "migration applied"));
|
||||
})
|
||||
.context("Failed to run migrations")?;
|
||||
Ok(Self {
|
||||
pool: Arc::new(pool),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn transaction<T>(&self, f: impl FnOnce(&Transaction) -> Result<T>) -> Result<T> {
|
||||
let mut conn = self.pool.get()?;
|
||||
let tx = conn.transaction().context("Failed to start transaction")?;
|
||||
let result = f(&tx)?;
|
||||
tx.commit().context("Failed to commit transaction")?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
CREATE TABLE `deltas` (
|
||||
`session_id` text NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`timestamp_ms` text NOT NULL,
|
||||
`operations` blob NOT NULL,
|
||||
`file_path` text NOT NULL,
|
||||
PRIMARY KEY (`project_id`, `session_id`, `timestamp_ms`, `file_path`)
|
||||
);
|
||||
|
||||
CREATE INDEX `deltas_project_id_session_id_index` ON `deltas` (`project_id`, `session_id`);
|
||||
|
||||
CREATE INDEX `deltas_project_id_session_id_file_path_index` ON `deltas` (`project_id`, `session_id`, `file_path`);
|
@ -1,11 +0,0 @@
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text NOT NULL PRIMARY KEY,
|
||||
`project_id` text NOT NULL,
|
||||
`hash` text,
|
||||
`branch` text,
|
||||
`commit` text,
|
||||
`start_timestamp_ms` text NOT NULL,
|
||||
`last_timestamp_ms` text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX `sessions_project_id_index` ON `sessions` (`project_id`);
|
@ -1,14 +0,0 @@
|
||||
CREATE TABLE `files` (
|
||||
`project_id` text NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`file_path` text NOT NULL,
|
||||
`sha1` blob NOT NULL,
|
||||
PRIMARY KEY (`project_id`, `session_id`, `file_path`)
|
||||
);
|
||||
|
||||
CREATE INDEX `files_project_id_session_id_index` ON `files` (`project_id`, `session_id`);
|
||||
|
||||
CREATE TABLE `contents` (
|
||||
`sha1` blob NOT NULL PRIMARY KEY,
|
||||
`content` blob NOT NULL
|
||||
);
|
@ -1,8 +0,0 @@
|
||||
CREATE TABLE `bookmarks` (
|
||||
`id` text NOT NULL PRIMARY KEY,
|
||||
`project_id` text NOT NULL,
|
||||
`timestamp_ms` text NOT NULL,
|
||||
`note` text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX bookmarks_project_id_idx ON `bookmarks` (`project_id`);
|
@ -1,16 +0,0 @@
|
||||
ALTER TABLE `bookmarks`
|
||||
ADD `created_timestamp_ms` text NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE
|
||||
`bookmarks`
|
||||
SET
|
||||
`created_timestamp_ms` = `timestamp_ms`;
|
||||
|
||||
ALTER TABLE `bookmarks`
|
||||
DROP COLUMN `timestamp_ms`;
|
||||
|
||||
ALTER TABLE `bookmarks`
|
||||
ADD `updated_timestamp_ms` text;
|
||||
|
||||
ALTER TABLE `bookmarks`
|
||||
ADD `deleted` boolean NOT NULL DEFAULT FALSE;
|
@ -1,28 +0,0 @@
|
||||
ALTER TABLE bookmarks RENAME TO bookmarks_old;
|
||||
|
||||
DROP INDEX `bookmarks_project_id_idx`;
|
||||
|
||||
CREATE TABLE bookmarks (
|
||||
`project_id` text NOT NULL,
|
||||
`timestamp_ms` text NOT NULL,
|
||||
`note` text NOT NULL,
|
||||
`deleted` boolean NOT NULL,
|
||||
`created_timestamp_ms` text NOT NULL,
|
||||
`updated_timestamp_ms` text NOT NULL,
|
||||
PRIMARY KEY (`project_id`, `timestamp_ms`)
|
||||
);
|
||||
|
||||
CREATE INDEX `bookmarks_project_id_idx` ON `bookmarks` (`project_id`);
|
||||
|
||||
INSERT INTO bookmarks (`project_id`, `timestamp_ms`, `note`, `deleted`, `created_timestamp_ms`, `updated_timestamp_ms`)
|
||||
SELECT
|
||||
`project_id`,
|
||||
`created_timestamp_ms`,
|
||||
`note`,
|
||||
`deleted`,
|
||||
`created_timestamp_ms`,
|
||||
`updated_timestamp_ms`
|
||||
FROM
|
||||
bookmarks_old;
|
||||
|
||||
DROP TABLE bookmarks_old;
|
@ -1 +0,0 @@
|
||||
CREATE INDEX `sessions_project_id_id_index` ON `sessions` (`project_id`, `id`);
|
@ -1,2 +0,0 @@
|
||||
DROP TABLE files;
|
||||
DROP TABLE contents;
|
@ -1 +0,0 @@
|
||||
DROP TABLE bookmarks;
|
@ -1,45 +0,0 @@
|
||||
pub(crate) fn dedup(existing: &[&str], new: &str) -> String {
|
||||
dedup_fmt(existing, new, " ")
|
||||
}
|
||||
|
||||
/// Makes sure that _new_ is not in _existing_ by adding a number to it.
|
||||
/// the number is increased until the name is unique.
|
||||
pub(crate) fn dedup_fmt(existing: &[&str], new: &str, separator: &str) -> String {
|
||||
existing
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
x.strip_prefix(new)
|
||||
.and_then(|x| x.strip_prefix(separator).or(Some("")))
|
||||
.and_then(|x| {
|
||||
if x.is_empty() {
|
||||
Some(0_i32)
|
||||
} else {
|
||||
x.parse::<i32>().ok()
|
||||
}
|
||||
})
|
||||
})
|
||||
.max()
|
||||
.map_or_else(
|
||||
|| new.to_string(),
|
||||
|x| format!("{new}{separator}{}", x + 1_i32),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tests() {
|
||||
for (existing, new, expected) in [
|
||||
(vec!["bar", "baz"], "foo", "foo"),
|
||||
(vec!["foo", "bar", "baz"], "foo", "foo 1"),
|
||||
(vec!["foo", "foo 2"], "foo", "foo 3"),
|
||||
(vec!["foo", "foo 1", "foo 2"], "foo", "foo 3"),
|
||||
(vec!["foo", "foo 1", "foo 2"], "foo 1", "foo 1 1"),
|
||||
(vec!["foo", "foo 1", "foo 2"], "foo 2", "foo 2 1"),
|
||||
(vec!["foo", "foo 1", "foo 2"], "foo 3", "foo 3"),
|
||||
(vec!["foo 2"], "foo", "foo 3"),
|
||||
(vec!["foo", "foo 1", "foo 2", "foo 4"], "foo", "foo 5"),
|
||||
(vec!["foo", "foo 0"], "foo", "foo 1"),
|
||||
(vec!["foo 0"], "foo", "foo 1"),
|
||||
] {
|
||||
assert_eq!(dedup(&existing, new), expected.to_string());
|
||||
}
|
||||
}
|
@ -1,16 +1,43 @@
|
||||
mod controller;
|
||||
mod delta;
|
||||
mod document;
|
||||
mod reader;
|
||||
mod writer;
|
||||
pub mod commands {
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub mod commands;
|
||||
pub mod database;
|
||||
pub mod operations;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
pub use controller::Controller;
|
||||
pub use database::Database;
|
||||
pub use delta::Delta;
|
||||
pub use document::Document;
|
||||
pub use reader::DeltasReader as Reader;
|
||||
pub use writer::DeltasWriter as Writer;
|
||||
use crate::error::{Code, Error};
|
||||
|
||||
use gitbutler::deltas::{controller::ListError, Controller, Delta};
|
||||
|
||||
impl From<ListError> for Error {
|
||||
fn from(value: ListError) -> Self {
|
||||
match value {
|
||||
ListError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_deltas(
|
||||
handle: AppHandle,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
paths: Option<Vec<&str>>,
|
||||
) -> Result<HashMap<String, Vec<Delta>>, Error> {
|
||||
let session_id = session_id.parse().map_err(|_| Error::UserError {
|
||||
message: "Malformed session id".to_string(),
|
||||
code: Code::Validation,
|
||||
})?;
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.list_by_session_id(&project_id, &session_id, &paths)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::{Code, Error};
|
||||
|
||||
use super::{controller::ListError, Controller, Delta};
|
||||
|
||||
impl From<ListError> for Error {
|
||||
fn from(value: ListError) -> Self {
|
||||
match value {
|
||||
ListError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_deltas(
|
||||
handle: AppHandle,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
paths: Option<Vec<&str>>,
|
||||
) -> Result<HashMap<String, Vec<Delta>>, Error> {
|
||||
let session_id = session_id.parse().map_err(|_| Error::UserError {
|
||||
message: "Malformed session id".to_string(),
|
||||
code: Code::Validation,
|
||||
})?;
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.list_by_session_id(&project_id, &session_id, &paths)
|
||||
.map_err(Into::into)
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{projects::ProjectId, sessions::SessionId};
|
||||
|
||||
use super::{database, Delta};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
database: database::Database,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ListError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(database: database::Database) -> Controller {
|
||||
Controller { database }
|
||||
}
|
||||
|
||||
pub fn list_by_session_id(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
session_id: &SessionId,
|
||||
paths: &Option<Vec<&str>>,
|
||||
) -> Result<HashMap<String, Vec<Delta>>, ListError> {
|
||||
self.database
|
||||
.list_by_project_id_session_id(project_id, session_id, paths)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
use std::{collections::HashMap, path};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{database, projects::ProjectId, sessions::SessionId};
|
||||
|
||||
use super::{delta, operations};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
database: database::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(database: database::Database) -> Database {
|
||||
Database { database }
|
||||
}
|
||||
|
||||
pub fn insert(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
session_id: &SessionId,
|
||||
file_path: &path::Path,
|
||||
deltas: &Vec<delta::Delta>,
|
||||
) -> Result<()> {
|
||||
self.database.transaction(|tx| -> Result<()> {
|
||||
let mut stmt = insert_stmt(tx).context("Failed to prepare insert statement")?;
|
||||
for delta in deltas {
|
||||
let operations = serde_json::to_vec(&delta.operations)
|
||||
.context("Failed to serialize operations")?;
|
||||
let timestamp_ms = delta.timestamp_ms.to_string();
|
||||
stmt.execute(rusqlite::named_params! {
|
||||
":project_id": project_id,
|
||||
":session_id": session_id,
|
||||
":file_path": file_path.display().to_string(),
|
||||
":timestamp_ms": timestamp_ms,
|
||||
":operations": operations,
|
||||
})
|
||||
.context("Failed to execute insert statement")?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_by_project_id_session_id(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
session_id: &SessionId,
|
||||
file_path_filter: &Option<Vec<&str>>,
|
||||
) -> Result<HashMap<String, Vec<delta::Delta>>> {
|
||||
self.database
|
||||
.transaction(|tx| -> Result<HashMap<String, Vec<delta::Delta>>> {
|
||||
let mut stmt = list_by_project_id_session_id_stmt(tx)
|
||||
.context("Failed to prepare query statement")?;
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::named_params! {
|
||||
":project_id": project_id,
|
||||
":session_id": session_id,
|
||||
})
|
||||
.context("Failed to execute query statement")?;
|
||||
let mut deltas: HashMap<String, Vec<super::Delta>> = HashMap::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.context("Failed to iterate over query results")?
|
||||
{
|
||||
let file_path: String = row.get(0).context("Failed to get file_path")?;
|
||||
if let Some(file_path_filter) = &file_path_filter {
|
||||
if !file_path_filter.contains(&file_path.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let timestamp_ms: String = row.get(1).context("Failed to get timestamp_ms")?;
|
||||
let operations: Vec<u8> = row.get(2).context("Failed to get operations")?;
|
||||
let operations: Vec<operations::Operation> =
|
||||
serde_json::from_slice(&operations)
|
||||
.context("Failed to deserialize operations")?;
|
||||
let timestamp_ms: u128 = timestamp_ms
|
||||
.parse()
|
||||
.context("Failed to parse timestamp_ms as u64")?;
|
||||
let delta = delta::Delta {
|
||||
operations,
|
||||
timestamp_ms,
|
||||
};
|
||||
if let Some(deltas_for_file_path) = deltas.get_mut(&file_path) {
|
||||
deltas_for_file_path.push(delta);
|
||||
} else {
|
||||
deltas.insert(file_path, vec![delta]);
|
||||
}
|
||||
}
|
||||
Ok(deltas)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn list_by_project_id_session_id_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"
|
||||
SELECT `file_path`, `timestamp_ms`, `operations`
|
||||
FROM `deltas`
|
||||
WHERE `session_id` = :session_id AND `project_id` = :project_id
|
||||
ORDER BY `timestamp_ms` ASC",
|
||||
)?)
|
||||
}
|
||||
|
||||
fn insert_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"INSERT INTO `deltas` (
|
||||
`project_id`, `session_id`, `timestamp_ms`, `operations`, `file_path`
|
||||
) VALUES (
|
||||
:project_id, :session_id, :timestamp_ms, :operations, :file_path
|
||||
)
|
||||
ON CONFLICT(`project_id`, `session_id`, `file_path`, `timestamp_ms`) DO UPDATE SET
|
||||
`operations` = :operations
|
||||
",
|
||||
)?)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
use super::operations;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delta {
|
||||
pub operations: Vec<operations::Operation>,
|
||||
pub timestamp_ms: u128,
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
use crate::reader;
|
||||
|
||||
use super::{delta, operations};
|
||||
use anyhow::Result;
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Document {
|
||||
doc: Vec<char>,
|
||||
deltas: Vec<delta::Delta>,
|
||||
}
|
||||
|
||||
fn apply_deltas(doc: &mut Vec<char>, deltas: &Vec<delta::Delta>) -> Result<()> {
|
||||
for delta in deltas {
|
||||
for operation in &delta.operations {
|
||||
operation.apply(doc)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn get_deltas(&self) -> Vec<delta::Delta> {
|
||||
self.deltas.clone()
|
||||
}
|
||||
|
||||
// returns a text document where internal state is seeded with value, and deltas are applied.
|
||||
pub fn new(value: Option<&reader::Content>, deltas: Vec<delta::Delta>) -> Result<Document> {
|
||||
let mut all_deltas = vec![];
|
||||
if let Some(reader::Content::UTF8(value)) = value {
|
||||
all_deltas.push(delta::Delta {
|
||||
operations: operations::get_delta_operations("", value),
|
||||
timestamp_ms: 0,
|
||||
});
|
||||
}
|
||||
all_deltas.append(&mut deltas.clone());
|
||||
let mut doc = vec![];
|
||||
apply_deltas(&mut doc, &all_deltas)?;
|
||||
Ok(Document { doc, deltas })
|
||||
}
|
||||
|
||||
pub fn update(&mut self, value: Option<&reader::Content>) -> Result<Option<delta::Delta>> {
|
||||
let new_text = match value {
|
||||
Some(reader::Content::UTF8(value)) => value,
|
||||
Some(_) | None => "",
|
||||
};
|
||||
|
||||
let operations = operations::get_delta_operations(&self.to_string(), new_text);
|
||||
let delta = if operations.is_empty() {
|
||||
if let Some(reader::Content::UTF8(value)) = value {
|
||||
if !value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
delta::Delta {
|
||||
operations,
|
||||
timestamp_ms: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
}
|
||||
} else {
|
||||
delta::Delta {
|
||||
operations,
|
||||
timestamp_ms: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
}
|
||||
};
|
||||
apply_deltas(&mut self.doc, &vec![delta.clone()])?;
|
||||
self.deltas.push(delta.clone());
|
||||
Ok(Some(delta))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Document {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.doc.iter().collect::<String>())
|
||||
}
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Operation {
|
||||
// corresponds to YText.insert(index, chunk)
|
||||
Insert((usize, String)),
|
||||
// corresponds to YText.remove_range(index, len)
|
||||
Delete((usize, usize)),
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub fn apply(&self, text: &mut Vec<char>) -> Result<()> {
|
||||
match self {
|
||||
Operation::Insert((index, chunk)) => match index.cmp(&text.len()) {
|
||||
Ordering::Greater => Err(anyhow::anyhow!(
|
||||
"Index out of bounds, {} > {}",
|
||||
index,
|
||||
text.len()
|
||||
)),
|
||||
Ordering::Equal => {
|
||||
text.extend(chunk.chars());
|
||||
Ok(())
|
||||
}
|
||||
Ordering::Less => {
|
||||
text.splice(*index..*index, chunk.chars());
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Operation::Delete((index, len)) => {
|
||||
if *index > text.len() {
|
||||
Err(anyhow::anyhow!(
|
||||
"Index out of bounds, {} > {}",
|
||||
index,
|
||||
text.len()
|
||||
))
|
||||
} else if *index + *len > text.len() {
|
||||
Err(anyhow::anyhow!(
|
||||
"Index + length out of bounds, {} > {}",
|
||||
index + len,
|
||||
text.len()
|
||||
))
|
||||
} else {
|
||||
text.splice(*index..(*index + *len), "".chars());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// merges touching operations of the same type in to one operation
|
||||
// e.g. [Insert((0, "hello")), Insert((5, " world"))] -> [Insert((0, "hello world"))]
|
||||
// e.g. [Delete((0, 5)), Delete((5, 5))] -> [Delete((0, 10))]
|
||||
// e.g. [Insert((0, "hello")), Delete((0, 5))] -> [Insert((0, "hello")), Delete((0, 5))]
|
||||
fn merge_touching(ops: &Vec<Operation>) -> Vec<Operation> {
|
||||
let mut merged = vec![];
|
||||
|
||||
for op in ops {
|
||||
match (merged.last_mut(), op) {
|
||||
(Some(Operation::Insert((index, chunk))), Operation::Insert((index2, chunk2))) => {
|
||||
if *index + chunk.len() == *index2 {
|
||||
chunk.push_str(chunk2);
|
||||
} else {
|
||||
merged.push(op.clone());
|
||||
}
|
||||
}
|
||||
(Some(Operation::Delete((index, len))), Operation::Delete((index2, len2))) => {
|
||||
if *index == *index2 {
|
||||
*len += len2;
|
||||
} else {
|
||||
merged.push(op.clone());
|
||||
}
|
||||
}
|
||||
_ => merged.push(op.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn get_delta_operations(initial_text: &str, final_text: &str) -> Vec<Operation> {
|
||||
if initial_text == final_text {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let changeset = TextDiff::configure().diff_graphemes(initial_text, final_text);
|
||||
let mut deltas = vec![];
|
||||
|
||||
let mut offset = 0;
|
||||
for change in changeset.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Delete => {
|
||||
deltas.push(Operation::Delete((
|
||||
offset,
|
||||
change.as_str().unwrap_or("").chars().count(),
|
||||
)));
|
||||
}
|
||||
ChangeTag::Insert => {
|
||||
let text = change.as_str().unwrap();
|
||||
deltas.push(Operation::Insert((offset, text.to_string())));
|
||||
offset = change.new_index().unwrap() + text.chars().count();
|
||||
}
|
||||
ChangeTag::Equal => {
|
||||
let text = change.as_str().unwrap();
|
||||
offset = change.new_index().unwrap() + text.chars().count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merge_touching(&deltas)
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
use std::{collections::HashMap, path};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{reader, sessions};
|
||||
|
||||
use super::Delta;
|
||||
|
||||
pub struct DeltasReader<'reader> {
|
||||
reader: &'reader reader::Reader<'reader>,
|
||||
}
|
||||
|
||||
impl<'reader> From<&'reader reader::Reader<'reader>> for DeltasReader<'reader> {
|
||||
fn from(reader: &'reader reader::Reader<'reader>) -> Self {
|
||||
DeltasReader { reader }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ReadError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl<'reader> DeltasReader<'reader> {
|
||||
pub fn new(reader: &'reader sessions::Reader<'reader>) -> Self {
|
||||
DeltasReader {
|
||||
reader: reader.reader(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Option<Vec<Delta>>> {
|
||||
match self.read(Some(&[path.as_ref()])) {
|
||||
Ok(deltas) => Ok(deltas.into_iter().next().map(|(_, deltas)| deltas)),
|
||||
Err(ReadError::NotFound) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(
|
||||
&self,
|
||||
filter: Option<&[&path::Path]>,
|
||||
) -> Result<HashMap<path::PathBuf, Vec<Delta>>, ReadError> {
|
||||
let deltas_dir = path::Path::new("session/deltas");
|
||||
let mut paths = self.reader.list_files(deltas_dir)?;
|
||||
if let Some(filter) = filter {
|
||||
paths = paths
|
||||
.into_iter()
|
||||
.filter(|file_path| filter.iter().any(|path| file_path.eq(path)))
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
paths = paths.iter().map(|path| deltas_dir.join(path)).collect();
|
||||
let files = self.reader.batch(&paths).context("failed to batch read")?;
|
||||
|
||||
let files = files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
file.map_err(|error| match error {
|
||||
reader::Error::NotFound => ReadError::NotFound,
|
||||
error => ReadError::Other(error.into()),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(paths
|
||||
.into_iter()
|
||||
.zip(files)
|
||||
.filter_map(|(path, file)| {
|
||||
path.strip_prefix(deltas_dir)
|
||||
.ok()
|
||||
.map(|path| (path.to_path_buf(), file))
|
||||
})
|
||||
.filter_map(|(path, file)| {
|
||||
if let reader::Content::UTF8(content) = file {
|
||||
if content.is_empty() {
|
||||
// this is a leftover from some bug, shouldn't happen anymore
|
||||
return None;
|
||||
}
|
||||
let deltas = serde_json::from_str(&content).ok()?;
|
||||
Some(Ok((path, deltas)))
|
||||
} else {
|
||||
Some(Err(anyhow::anyhow!("unexpected content type")))
|
||||
}
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>>>()?)
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{gb_repository, writer};
|
||||
|
||||
use super::Delta;
|
||||
|
||||
pub struct DeltasWriter<'writer> {
|
||||
repository: &'writer gb_repository::Repository,
|
||||
writer: writer::DirWriter,
|
||||
}
|
||||
|
||||
impl<'writer> DeltasWriter<'writer> {
|
||||
pub fn new(repository: &'writer gb_repository::Repository) -> Result<Self, std::io::Error> {
|
||||
writer::DirWriter::open(repository.root()).map(|writer| Self { repository, writer })
|
||||
}
|
||||
|
||||
pub fn write<P: AsRef<std::path::Path>>(&self, path: P, deltas: &Vec<Delta>) -> Result<()> {
|
||||
self.repository.mark_active_session()?;
|
||||
|
||||
let _lock = self.repository.lock();
|
||||
|
||||
let path = path.as_ref();
|
||||
let raw_deltas = serde_json::to_string(&deltas)?;
|
||||
|
||||
self.writer
|
||||
.write_string(PathBuf::from("session/deltas").join(path), &raw_deltas)?;
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.repository.get_project_id(),
|
||||
path = %path.display(),
|
||||
"wrote deltas"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_wd_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
|
||||
self.repository.mark_active_session()?;
|
||||
|
||||
let _lock = self.repository.lock();
|
||||
|
||||
let path = path.as_ref();
|
||||
self.writer.remove(PathBuf::from("session/wd").join(path))?;
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.repository.get_project_id(),
|
||||
path = %path.display(),
|
||||
"deleted session wd file"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_wd_file<P: AsRef<std::path::Path>>(&self, path: P, contents: &str) -> Result<()> {
|
||||
self.repository.mark_active_session()?;
|
||||
|
||||
let _lock = self.repository.lock();
|
||||
|
||||
let path = path.as_ref();
|
||||
self.writer
|
||||
.write_string(PathBuf::from("session/wd").join(path), contents)?;
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.repository.get_project_id(),
|
||||
path = %path.display(),
|
||||
"wrote session wd file"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
#[cfg(feature = "sentry")]
|
||||
mod sentry;
|
||||
|
||||
pub use legacy::*;
|
||||
pub(crate) use legacy::*;
|
||||
|
||||
pub mod gb {
|
||||
pub(crate) mod gb {
|
||||
#[cfg(feature = "error-context")]
|
||||
pub use error_context::*;
|
||||
|
||||
@ -319,6 +319,7 @@ pub mod gb {
|
||||
mod legacy {
|
||||
use core::fmt;
|
||||
|
||||
use gitbutler::project_repository;
|
||||
use serde::{ser::SerializeMap, Serialize};
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -389,4 +390,19 @@ mod legacy {
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
impl From<project_repository::OpenError> for Error {
|
||||
fn from(value: project_repository::OpenError) -> Self {
|
||||
match value {
|
||||
project_repository::OpenError::NotFound(path) => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: format!("{} not found", path.display()),
|
||||
},
|
||||
project_repository::OpenError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::{
|
||||
use gitbutler::{
|
||||
deltas,
|
||||
projects::ProjectId,
|
||||
reader,
|
||||
|
@ -1,30 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Returns an ordered list of relative paths for files inside a directory recursively.
|
||||
pub fn list_files<P: AsRef<Path>>(dir_path: P, ignore_prefixes: &[P]) -> Result<Vec<PathBuf>> {
|
||||
let mut files = vec![];
|
||||
let dir_path = dir_path.as_ref();
|
||||
if !dir_path.exists() {
|
||||
return Ok(files);
|
||||
}
|
||||
for entry in WalkDir::new(dir_path) {
|
||||
let entry = entry?;
|
||||
if !entry.file_type().is_dir() {
|
||||
let path = entry.path();
|
||||
let path = path.strip_prefix(dir_path)?;
|
||||
let path = path.to_path_buf();
|
||||
if ignore_prefixes
|
||||
.iter()
|
||||
.any(|prefix| path.starts_with(prefix.as_ref()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
mod repository;
|
||||
|
||||
pub use repository::{RemoteError, Repository};
|
@ -1,967 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::File,
|
||||
io::{BufReader, Read},
|
||||
path, time,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::windows::MetadataShim;
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::prelude::*;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use filetime::FileTime;
|
||||
use fslock::LockFile;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
deltas, fs, git, project_repository,
|
||||
projects::{self, ProjectId},
|
||||
reader, sessions,
|
||||
sessions::SessionId,
|
||||
users,
|
||||
virtual_branches::{self, target},
|
||||
};
|
||||
|
||||
pub struct Repository {
|
||||
git_repository: git::Repository,
|
||||
project: projects::Project,
|
||||
lock_path: path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("path not found: {0}")]
|
||||
ProjectPathNotFound(path::PathBuf),
|
||||
#[error(transparent)]
|
||||
Git(#[from] git::Error),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
#[error("path has invalid utf-8 bytes: {0}")]
|
||||
InvalidUnicodePath(path::PathBuf),
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
pub fn open(
|
||||
root: &path::Path,
|
||||
project_repository: &project_repository::Repository,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<Self, Error> {
|
||||
let project = project_repository.project();
|
||||
let project_objects_path = project.path.join(".git/objects");
|
||||
if !project_objects_path.exists() {
|
||||
return Err(Error::ProjectPathNotFound(project_objects_path));
|
||||
}
|
||||
|
||||
let projects_dir = root.join("projects");
|
||||
let path = projects_dir.join(project.id.to_string());
|
||||
|
||||
let lock_path = projects_dir.join(format!("{}.lock", project.id));
|
||||
|
||||
if path.exists() {
|
||||
let git_repository = git::Repository::open(path.clone())
|
||||
.with_context(|| format!("{}: failed to open git repository", path.display()))?;
|
||||
|
||||
git_repository
|
||||
.add_disk_alternate(project_objects_path.to_str().unwrap())
|
||||
.context("failed to add disk alternate")?;
|
||||
|
||||
Result::Ok(Self {
|
||||
git_repository,
|
||||
project: project.clone(),
|
||||
lock_path,
|
||||
})
|
||||
} else {
|
||||
std::fs::create_dir_all(&path).context("failed to create project directory")?;
|
||||
|
||||
let git_repository = git::Repository::init_opts(
|
||||
&path,
|
||||
git2::RepositoryInitOptions::new()
|
||||
.bare(true)
|
||||
.initial_head("refs/heads/current")
|
||||
.external_template(false),
|
||||
)
|
||||
.with_context(|| format!("{}: failed to initialize git repository", path.display()))?;
|
||||
|
||||
git_repository
|
||||
.add_disk_alternate(project_objects_path.to_str().unwrap())
|
||||
.context("failed to add disk alternate")?;
|
||||
|
||||
let gb_repository = Self {
|
||||
git_repository,
|
||||
project: project.clone(),
|
||||
lock_path,
|
||||
};
|
||||
|
||||
let _lock = gb_repository.lock();
|
||||
let session = gb_repository.create_current_session(project_repository)?;
|
||||
drop(_lock);
|
||||
|
||||
gb_repository
|
||||
.flush_session(project_repository, &session, user)
|
||||
.context("failed to run initial flush")?;
|
||||
|
||||
Result::Ok(gb_repository)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_project_id(&self) -> &ProjectId {
|
||||
&self.project.id
|
||||
}
|
||||
|
||||
fn remote(&self, user: Option<&users::User>) -> Result<Option<(git::Remote, String)>> {
|
||||
// only push if logged in
|
||||
let access_token = match user {
|
||||
Some(user) => user.access_token.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// only push if project is connected
|
||||
let remote_url = match &self.project.api {
|
||||
Some(api) => api.git_url.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let remote = self
|
||||
.git_repository
|
||||
.remote_anonymous(&remote_url.parse().unwrap())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create anonymous remote for {}",
|
||||
remote_url.as_str()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some((remote, access_token)))
|
||||
}
|
||||
|
||||
pub fn fetch(&self, user: Option<&users::User>) -> Result<(), RemoteError> {
|
||||
let (mut remote, access_token) = match self.remote(user)? {
|
||||
Some((remote, access_token)) => (remote, access_token),
|
||||
None => return Result::Ok(()),
|
||||
};
|
||||
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
if self.project.omit_certificate_check.unwrap_or(false) {
|
||||
callbacks.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
}
|
||||
callbacks.push_update_reference(move |refname, message| {
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
refname,
|
||||
message,
|
||||
"pulling reference"
|
||||
);
|
||||
Result::Ok(())
|
||||
});
|
||||
callbacks.push_transfer_progress(move |one, two, three| {
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
"transferred {}/{}/{} objects",
|
||||
one,
|
||||
two,
|
||||
three
|
||||
);
|
||||
});
|
||||
|
||||
let mut fetch_opts = git2::FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(callbacks);
|
||||
let auth_header = format!("Authorization: {}", access_token);
|
||||
let headers = &[auth_header.as_str()];
|
||||
fetch_opts.custom_headers(headers);
|
||||
|
||||
remote
|
||||
.fetch(&["refs/heads/*:refs/remotes/*"], Some(&mut fetch_opts))
|
||||
.map_err(|error| match error {
|
||||
git::Error::Network(error) => {
|
||||
tracing::warn!(project_id = %self.project.id, error = %error, "failed to fetch gb repo");
|
||||
RemoteError::Network
|
||||
}
|
||||
error => RemoteError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
project_id = %self.project.id,
|
||||
"gb repo fetched",
|
||||
);
|
||||
|
||||
Result::Ok(())
|
||||
}
|
||||
|
||||
pub fn push(&self, user: Option<&users::User>) -> Result<(), RemoteError> {
|
||||
let (mut remote, access_token) = match self.remote(user)? {
|
||||
Some((remote, access_token)) => (remote, access_token),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Set the remote's callbacks
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
if self.project.omit_certificate_check.unwrap_or(false) {
|
||||
callbacks.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
}
|
||||
callbacks.push_update_reference(move |refname, message| {
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
refname,
|
||||
message,
|
||||
"pushing reference"
|
||||
);
|
||||
Result::Ok(())
|
||||
});
|
||||
callbacks.push_transfer_progress(move |current, total, bytes| {
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
"transferred {}/{}/{} objects",
|
||||
current,
|
||||
total,
|
||||
bytes
|
||||
);
|
||||
});
|
||||
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
push_options.remote_callbacks(callbacks);
|
||||
let auth_header = format!("Authorization: {}", access_token);
|
||||
let headers = &[auth_header.as_str()];
|
||||
push_options.custom_headers(headers);
|
||||
|
||||
let remote_refspec = format!("refs/heads/current:refs/heads/{}", self.project.id);
|
||||
|
||||
// Push to the remote
|
||||
remote
|
||||
.push(&[&remote_refspec], Some(&mut push_options)).map_err(|error| match error {
|
||||
git::Error::Network(error) => {
|
||||
tracing::warn!(project_id = %self.project.id, error = %error, "failed to push gb repo");
|
||||
RemoteError::Network
|
||||
}
|
||||
error => RemoteError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
tracing::info!(project_id = %self.project.id, "gb repository pushed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// take branches from the last session and put them into the current session
|
||||
fn copy_branches(&self) -> Result<()> {
|
||||
let last_session = self
|
||||
.get_sessions_iterator()
|
||||
.context("failed to get sessions iterator")?
|
||||
.next();
|
||||
if last_session.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let last_session = last_session
|
||||
.unwrap()
|
||||
.context("failed to read last session")?;
|
||||
let last_session_reader = sessions::Reader::open(self, &last_session)
|
||||
.context("failed to open last session reader")?;
|
||||
|
||||
let branches = virtual_branches::Iterator::new(&last_session_reader)
|
||||
.context("failed to read virtual branches")?
|
||||
.collect::<Result<Vec<_>, reader::Error>>()
|
||||
.context("failed to read virtual branches")?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let src_target_reader = virtual_branches::target::Reader::new(&last_session_reader);
|
||||
let dst_target_writer = virtual_branches::target::Writer::new(self, self.project.gb_dir())
|
||||
.context("failed to open target writer for current session")?;
|
||||
|
||||
// copy default target
|
||||
let default_target = match src_target_reader.read_default() {
|
||||
Result::Ok(target) => Ok(Some(target)),
|
||||
Err(reader::Error::NotFound) => Ok(None),
|
||||
Err(err) => Err(err).context("failed to read default target"),
|
||||
}?;
|
||||
if let Some(default_target) = default_target.as_ref() {
|
||||
dst_target_writer
|
||||
.write_default(default_target)
|
||||
.context("failed to write default target")?;
|
||||
}
|
||||
|
||||
// copy branch targets
|
||||
for branch in &branches {
|
||||
let target = src_target_reader
|
||||
.read(&branch.id)
|
||||
.with_context(|| format!("{}: failed to read target", branch.id))?;
|
||||
if let Some(default_target) = default_target.as_ref() {
|
||||
if *default_target == target {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
dst_target_writer
|
||||
.write(&branch.id, &target)
|
||||
.with_context(|| format!("{}: failed to write target", branch.id))?;
|
||||
}
|
||||
|
||||
let dst_branch_writer = virtual_branches::branch::Writer::new(self, self.project.gb_dir())
|
||||
.context("failed to open branch writer for current session")?;
|
||||
|
||||
// copy branches that we don't already have
|
||||
for branch in &branches {
|
||||
dst_branch_writer
|
||||
.write(&mut branch.clone())
|
||||
.with_context(|| format!("{}: failed to write branch", branch.id))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_current_session(
|
||||
&self,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<sessions::Session> {
|
||||
let now_ms = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
|
||||
let meta = match project_repository.get_head() {
|
||||
Result::Ok(head) => sessions::Meta {
|
||||
start_timestamp_ms: now_ms,
|
||||
last_timestamp_ms: now_ms,
|
||||
branch: head.name().map(|name| name.to_string()),
|
||||
commit: Some(head.peel_to_commit()?.id().to_string()),
|
||||
},
|
||||
Err(_) => sessions::Meta {
|
||||
start_timestamp_ms: now_ms,
|
||||
last_timestamp_ms: now_ms,
|
||||
branch: None,
|
||||
commit: None,
|
||||
},
|
||||
};
|
||||
|
||||
let session = sessions::Session {
|
||||
id: SessionId::generate(),
|
||||
hash: None,
|
||||
meta,
|
||||
};
|
||||
|
||||
// write session to disk
|
||||
sessions::Writer::new(self)
|
||||
.context("failed to create session writer")?
|
||||
.write(&session)
|
||||
.context("failed to write session")?;
|
||||
|
||||
tracing::info!(
|
||||
project_id = %self.project.id,
|
||||
session_id = %session.id,
|
||||
"created new session"
|
||||
);
|
||||
|
||||
self.flush_gitbutler_file(&session.id)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn lock(&self) -> LockFile {
|
||||
let mut lockfile = LockFile::open(&self.lock_path).expect("failed to open lock file");
|
||||
lockfile.lock().expect("failed to obtain lock on lock file");
|
||||
lockfile
|
||||
}
|
||||
|
||||
pub fn mark_active_session(&self) -> Result<()> {
|
||||
let current_session = self
|
||||
.get_or_create_current_session()
|
||||
.context("failed to get current session")?;
|
||||
|
||||
let updated_session = sessions::Session {
|
||||
meta: sessions::Meta {
|
||||
last_timestamp_ms: time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
..current_session.meta
|
||||
},
|
||||
..current_session
|
||||
};
|
||||
|
||||
sessions::Writer::new(self)
|
||||
.context("failed to create session writer")?
|
||||
.write(&updated_session)
|
||||
.context("failed to write session")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_latest_session(&self) -> Result<Option<sessions::Session>> {
|
||||
if let Some(current_session) = self.get_current_session()? {
|
||||
Ok(Some(current_session))
|
||||
} else {
|
||||
let mut sessions_iterator = self.get_sessions_iterator()?;
|
||||
sessions_iterator
|
||||
.next()
|
||||
.transpose()
|
||||
.context("failed to get latest session")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_create_current_session(&self) -> Result<sessions::Session> {
|
||||
let _lock = self.lock();
|
||||
|
||||
let reader = reader::Reader::open(&self.root())?;
|
||||
match sessions::Session::try_from(&reader) {
|
||||
Result::Ok(session) => Ok(session),
|
||||
Err(sessions::SessionError::NoSession) => {
|
||||
let project_repository = project_repository::Repository::open(&self.project)
|
||||
.context("failed to open project repository")?;
|
||||
let session = self
|
||||
.create_current_session(&project_repository)
|
||||
.context("failed to create current session")?;
|
||||
drop(_lock);
|
||||
self.copy_branches().context("failed to unpack branches")?;
|
||||
Ok(session)
|
||||
}
|
||||
Err(err) => Err(err).context("failed to read current session"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flush(
|
||||
&self,
|
||||
project_repository: &project_repository::Repository,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<Option<sessions::Session>> {
|
||||
let current_session = self
|
||||
.get_current_session()
|
||||
.context("failed to get current session")?;
|
||||
if current_session.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let current_session = current_session.unwrap();
|
||||
let current_session = self
|
||||
.flush_session(project_repository, ¤t_session, user)
|
||||
.context(format!("failed to flush session {}", current_session.id))?;
|
||||
Ok(Some(current_session))
|
||||
}
|
||||
|
||||
pub fn flush_session(
|
||||
&self,
|
||||
project_repository: &project_repository::Repository,
|
||||
session: &sessions::Session,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<sessions::Session> {
|
||||
if session.hash.is_some() {
|
||||
return Ok(session.clone());
|
||||
}
|
||||
|
||||
if !self.root().exists() {
|
||||
return Err(anyhow!("nothing to flush"));
|
||||
}
|
||||
|
||||
let _lock = self.lock();
|
||||
|
||||
// update last timestamp
|
||||
let session_writer =
|
||||
sessions::Writer::new(self).context("failed to create session writer")?;
|
||||
session_writer.write(session)?;
|
||||
|
||||
let mut tree_builder = self.git_repository.treebuilder(None);
|
||||
|
||||
tree_builder.upsert(
|
||||
"session",
|
||||
build_session_tree(self).context("failed to build session tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
tree_builder.upsert(
|
||||
"wd",
|
||||
build_wd_tree(self, project_repository)
|
||||
.context("failed to build working directory tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
tree_builder.upsert(
|
||||
"branches",
|
||||
build_branches_tree(self).context("failed to build branches tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
|
||||
let tree_id = tree_builder.write().context("failed to write tree")?;
|
||||
|
||||
let commit_oid =
|
||||
write_gb_commit(tree_id, self, user).context("failed to write gb commit")?;
|
||||
|
||||
tracing::info!(
|
||||
project_id = %self.project.id,
|
||||
session_id = %session.id,
|
||||
%commit_oid,
|
||||
"flushed session"
|
||||
);
|
||||
|
||||
session_writer.remove()?;
|
||||
|
||||
let session = sessions::Session {
|
||||
hash: Some(commit_oid),
|
||||
..session.clone()
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn get_sessions_iterator(&self) -> Result<sessions::SessionsIterator<'_>> {
|
||||
sessions::SessionsIterator::new(&self.git_repository)
|
||||
}
|
||||
|
||||
pub fn get_current_session(&self) -> Result<Option<sessions::Session>> {
|
||||
let _lock = self.lock();
|
||||
let reader = reader::Reader::open(&self.root())?;
|
||||
match sessions::Session::try_from(&reader) {
|
||||
Ok(session) => Ok(Some(session)),
|
||||
Err(sessions::SessionError::NoSession) => Ok(None),
|
||||
Err(sessions::SessionError::Other(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> std::path::PathBuf {
|
||||
self.git_repository.path().join("gitbutler")
|
||||
}
|
||||
|
||||
pub fn session_path(&self) -> std::path::PathBuf {
|
||||
self.root().join("session")
|
||||
}
|
||||
|
||||
pub fn git_repository_path(&self) -> &std::path::Path {
|
||||
self.git_repository.path()
|
||||
}
|
||||
|
||||
pub fn session_wd_path(&self) -> std::path::PathBuf {
|
||||
self.session_path().join("wd")
|
||||
}
|
||||
|
||||
pub fn default_target(&self) -> Result<Option<target::Target>> {
|
||||
if let Some(latest_session) = self.get_latest_session()? {
|
||||
let latest_session_reader = sessions::Reader::open(self, &latest_session)
|
||||
.context("failed to open current session")?;
|
||||
let target_reader = target::Reader::new(&latest_session_reader);
|
||||
match target_reader.read_default() {
|
||||
Result::Ok(target) => Ok(Some(target)),
|
||||
Err(reader::Error::NotFound) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_gitbutler_file(&self, session_id: &SessionId) -> Result<()> {
|
||||
let gb_path = self.git_repository.path();
|
||||
let project_id = self.project.id.to_string();
|
||||
let gb_file_content = serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"repositoryId": project_id,
|
||||
"gbPath": gb_path,
|
||||
"api": self.project.api,
|
||||
});
|
||||
|
||||
let gb_file_path = self.project.path.join(".git/gitbutler.json");
|
||||
std::fs::write(&gb_file_path, gb_file_content.to_string())?;
|
||||
|
||||
tracing::debug!("gitbutler file updated: {:?}", gb_file_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_repository(&self) -> &git::Repository {
|
||||
&self.git_repository
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wd_tree(
|
||||
gb_repository: &Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<git::Oid> {
|
||||
match gb_repository
|
||||
.git_repository
|
||||
.find_reference(&"refs/heads/current".parse().unwrap())
|
||||
{
|
||||
Result::Ok(reference) => build_wd_tree_from_reference(gb_repository, &reference)
|
||||
.context("failed to build wd index"),
|
||||
Err(git::Error::NotFound(_)) => build_wd_tree_from_repo(gb_repository, project_repository)
|
||||
.context("failed to build wd index"),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wd_tree_from_reference(
|
||||
gb_repository: &Repository,
|
||||
reference: &git::Reference,
|
||||
) -> Result<git::Oid> {
|
||||
// start off with the last tree as a base
|
||||
let tree = reference.peel_to_tree()?;
|
||||
let wd_tree_entry = tree.get_name("wd").unwrap();
|
||||
let wd_tree = gb_repository.git_repository.find_tree(wd_tree_entry.id())?;
|
||||
let mut index = git::Index::try_from(&wd_tree)?;
|
||||
|
||||
// write updated files on top of the last tree
|
||||
for file_path in fs::list_files(gb_repository.session_wd_path(), &[]).with_context(|| {
|
||||
format!(
|
||||
"failed to session working directory files list files in {}",
|
||||
gb_repository.session_wd_path().display()
|
||||
)
|
||||
})? {
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
&gb_repository.session_wd_path(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add session working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let session_reader = reader::Reader::open(&gb_repository.root())?;
|
||||
let deltas = deltas::Reader::from(&session_reader)
|
||||
.read(None)
|
||||
.context("failed to read deltas")?;
|
||||
let wd_files = session_reader.list_files(path::Path::new("session/wd"))?;
|
||||
let wd_files = wd_files.iter().collect::<HashSet<_>>();
|
||||
|
||||
// if a file has delta, but doesn't exist in wd, it was deleted
|
||||
let deleted_files = deltas
|
||||
.keys()
|
||||
.filter(|key| !wd_files.contains(key))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for deleted_file in deleted_files {
|
||||
index
|
||||
.remove_path(deleted_file)
|
||||
.context("failed to remove path")?;
|
||||
}
|
||||
|
||||
let wd_tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write wd tree")?;
|
||||
Ok(wd_tree_oid)
|
||||
}
|
||||
|
||||
// build wd index from the working directory files new session wd files
|
||||
// this is important because we want to make sure session files are in sync with session deltas
|
||||
fn build_wd_tree_from_repo(
|
||||
gb_repository: &Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
let mut added: HashMap<String, bool> = HashMap::new();
|
||||
|
||||
// first, add session/wd files. session/wd are written at the same time as deltas, so it's important to add them first
|
||||
// to make sure they are in sync with the deltas
|
||||
for file_path in fs::list_files(gb_repository.session_wd_path(), &[]).with_context(|| {
|
||||
format!(
|
||||
"failed to session working directory files list files in {}",
|
||||
gb_repository.session_wd_path().display()
|
||||
)
|
||||
})? {
|
||||
if project_repository
|
||||
.git_repository
|
||||
.is_path_ignored(&file_path)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
&gb_repository.session_wd_path(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add session working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
added.insert(file_path.to_string_lossy().to_string(), true);
|
||||
}
|
||||
|
||||
// finally, add files from the working directory if they aren't already in the index
|
||||
for file_path in fs::list_files(project_repository.root(), &[path::Path::new(".git")])
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to working directory list files in {}",
|
||||
project_repository.root().display()
|
||||
)
|
||||
})?
|
||||
{
|
||||
if added.contains_key(&file_path.to_string_lossy().to_string()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if project_repository
|
||||
.git_repository
|
||||
.is_path_ignored(&file_path)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
project_repository.root(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write tree to repo")?;
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
// take a file path we see and add it to our in-memory index
|
||||
// we call this from build_initial_wd_tree, which is smart about using the existing index to avoid rehashing files that haven't changed
|
||||
// and also looks for large files and puts in a placeholder hash in the LFS format
|
||||
// TODO: actually upload the file to LFS
|
||||
fn add_wd_path(
|
||||
index: &mut git::Index,
|
||||
dir: &std::path::Path,
|
||||
rel_file_path: &std::path::Path,
|
||||
gb_repository: &Repository,
|
||||
) -> Result<()> {
|
||||
let file_path = dir.join(rel_file_path);
|
||||
|
||||
let metadata = std::fs::symlink_metadata(&file_path).context("failed to get metadata for")?;
|
||||
let modify_time = FileTime::from_last_modification_time(&metadata);
|
||||
let create_time = FileTime::from_creation_time(&metadata).unwrap_or(modify_time);
|
||||
|
||||
// look for files that are bigger than 4GB, which are not supported by git
|
||||
// insert a pointer as the blob content instead
|
||||
// TODO: size limit should be configurable
|
||||
let blob = if metadata.is_symlink() {
|
||||
// it's a symlink, make the content the path of the link
|
||||
let link_target = std::fs::read_link(&file_path)?;
|
||||
// if the link target is inside the project repository, make it relative
|
||||
let link_target = link_target.strip_prefix(dir).unwrap_or(&link_target);
|
||||
gb_repository.git_repository.blob(
|
||||
link_target
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::InvalidUnicodePath(link_target.into()))?
|
||||
.as_bytes(),
|
||||
)?
|
||||
} else if metadata.len() > 100_000_000 {
|
||||
tracing::warn!(
|
||||
project_id = %gb_repository.project.id,
|
||||
path = %file_path.display(),
|
||||
"file too big"
|
||||
);
|
||||
|
||||
// get a sha256 hash of the file first
|
||||
let sha = sha256_digest(&file_path)?;
|
||||
|
||||
// put togther a git lfs pointer file: https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md
|
||||
let mut lfs_pointer = String::from("version https://git-lfs.github.com/spec/v1\n");
|
||||
lfs_pointer.push_str("oid sha256:");
|
||||
lfs_pointer.push_str(&sha);
|
||||
lfs_pointer.push('\n');
|
||||
lfs_pointer.push_str("size ");
|
||||
lfs_pointer.push_str(&metadata.len().to_string());
|
||||
lfs_pointer.push('\n');
|
||||
|
||||
// write the file to the .git/lfs/objects directory
|
||||
// create the directory recursively if it doesn't exist
|
||||
let lfs_objects_dir = gb_repository.git_repository.path().join("lfs/objects");
|
||||
std::fs::create_dir_all(lfs_objects_dir.clone())?;
|
||||
let lfs_path = lfs_objects_dir.join(sha);
|
||||
std::fs::copy(file_path, lfs_path)?;
|
||||
|
||||
gb_repository.git_repository.blob(lfs_pointer.as_bytes())?
|
||||
} else {
|
||||
// read the file into a blob, get the object id
|
||||
gb_repository.git_repository.blob_path(&file_path)?
|
||||
};
|
||||
|
||||
// create a new IndexEntry from the file metadata
|
||||
// truncation is ok https://libgit2.org/libgit2/#HEAD/type/git_index_entry
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
index
|
||||
.add(&git::IndexEntry {
|
||||
ctime: create_time,
|
||||
mtime: modify_time,
|
||||
dev: metadata.dev() as u32,
|
||||
ino: metadata.ino() as u32,
|
||||
mode: 33188,
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
file_size: metadata.len() as u32,
|
||||
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
|
||||
flags_extended: 0, // no extended flags
|
||||
path: rel_file_path.to_str().unwrap().to_string().into(),
|
||||
id: blob,
|
||||
})
|
||||
.with_context(|| format!("failed to add index entry for {}", rel_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// calculates sha256 digest of a large file as lowercase hex string via streaming buffer
|
||||
/// used to calculate the hash of large files that are not supported by git
|
||||
fn sha256_digest(path: &std::path::Path) -> Result<String> {
|
||||
let input = File::open(path)?;
|
||||
let mut reader = BufReader::new(input);
|
||||
|
||||
let digest = {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0; 1024];
|
||||
loop {
|
||||
let count = reader.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
hasher.finalize()
|
||||
};
|
||||
Ok(format!("{:X}", digest))
|
||||
}
|
||||
|
||||
fn build_branches_tree(gb_repository: &Repository) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
let branches_dir = gb_repository.root().join("branches");
|
||||
for file_path in
|
||||
fs::list_files(&branches_dir, &[]).context("failed to find branches directory")?
|
||||
{
|
||||
let file_path = std::path::Path::new(&file_path);
|
||||
add_file_to_index(
|
||||
gb_repository,
|
||||
&mut index,
|
||||
file_path,
|
||||
&branches_dir.join(file_path),
|
||||
)
|
||||
.context("failed to add branch file to index")?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write index to tree")?;
|
||||
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
fn build_session_tree(gb_repository: &Repository) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
// add all files in the working directory to the in-memory index, skipping for matching entries in the repo index
|
||||
for file_path in fs::list_files(
|
||||
gb_repository.session_path(),
|
||||
&[path::Path::new("wd").to_path_buf()],
|
||||
)
|
||||
.context("failed to list session files")?
|
||||
{
|
||||
add_file_to_index(
|
||||
gb_repository,
|
||||
&mut index,
|
||||
&file_path,
|
||||
&gb_repository.session_path().join(&file_path),
|
||||
)
|
||||
.with_context(|| format!("failed to add session file: {}", file_path.display()))?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write index to tree")?;
|
||||
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
// this is a helper function for build_gb_tree that takes paths under .git/gb/session and adds them to the in-memory index
|
||||
fn add_file_to_index(
|
||||
gb_repository: &Repository,
|
||||
index: &mut git::Index,
|
||||
rel_file_path: &std::path::Path,
|
||||
abs_file_path: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let blob = gb_repository.git_repository.blob_path(abs_file_path)?;
|
||||
let metadata = abs_file_path.metadata()?;
|
||||
let modified_time = FileTime::from_last_modification_time(&metadata);
|
||||
let create_time = FileTime::from_creation_time(&metadata).unwrap_or(modified_time);
|
||||
|
||||
// create a new IndexEntry from the file metadata
|
||||
// truncation is ok https://libgit2.org/libgit2/#HEAD/type/git_index_entry
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
index
|
||||
.add(&git::IndexEntry {
|
||||
ctime: create_time,
|
||||
mtime: modified_time,
|
||||
dev: metadata.dev() as u32,
|
||||
ino: metadata.ino() as u32,
|
||||
mode: 33188,
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
file_size: metadata.len() as u32,
|
||||
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
|
||||
flags_extended: 0, // no extended flags
|
||||
path: rel_file_path.to_str().unwrap().into(),
|
||||
id: blob,
|
||||
})
|
||||
.with_context(|| format!("Failed to add file to index: {}", abs_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// write a new commit object to the repo
|
||||
// this is called once we have a tree of deltas, metadata and current wd snapshot
|
||||
// and either creates or updates the refs/heads/current ref
|
||||
fn write_gb_commit(
|
||||
tree_id: git::Oid,
|
||||
gb_repository: &Repository,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<git::Oid> {
|
||||
let comitter = git::Signature::now("gitbutler", "gitbutler@localhost")?;
|
||||
let author = match user {
|
||||
None => comitter.clone(),
|
||||
Some(user) => git::Signature::try_from(user)?,
|
||||
};
|
||||
|
||||
let current_refname: git::Refname = "refs/heads/current".parse().unwrap();
|
||||
|
||||
match gb_repository
|
||||
.git_repository
|
||||
.find_reference(¤t_refname)
|
||||
{
|
||||
Result::Ok(reference) => {
|
||||
let last_commit = reference.peel_to_commit()?;
|
||||
let new_commit = gb_repository.git_repository.commit(
|
||||
Some(¤t_refname),
|
||||
&author, // author
|
||||
&comitter, // committer
|
||||
"gitbutler check", // commit message
|
||||
&gb_repository.git_repository.find_tree(tree_id).unwrap(), // tree
|
||||
&[&last_commit], // parents
|
||||
)?;
|
||||
Ok(new_commit)
|
||||
}
|
||||
Err(git::Error::NotFound(_)) => {
|
||||
let new_commit = gb_repository.git_repository.commit(
|
||||
Some(¤t_refname),
|
||||
&author, // author
|
||||
&comitter, // committer
|
||||
"gitbutler check", // commit message
|
||||
&gb_repository.git_repository.find_tree(tree_id).unwrap(), // tree
|
||||
&[], // parents
|
||||
)?;
|
||||
Ok(new_commit)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteError {
|
||||
#[error("network error")]
|
||||
Network,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
pub mod credentials;
|
||||
pub mod diff;
|
||||
pub mod show;
|
||||
|
||||
mod blob;
|
||||
pub use blob::*;
|
||||
|
||||
mod error;
|
||||
pub use error::*;
|
||||
|
||||
mod reference;
|
||||
pub use reference::*;
|
||||
mod repository;
|
||||
|
||||
pub use repository::*;
|
||||
|
||||
mod commit;
|
||||
pub use commit::*;
|
||||
|
||||
mod branch;
|
||||
pub use branch::*;
|
||||
|
||||
mod tree;
|
||||
pub use tree::*;
|
||||
|
||||
mod remote;
|
||||
pub use remote::*;
|
||||
|
||||
mod index;
|
||||
pub use index::*;
|
||||
|
||||
mod oid;
|
||||
pub use oid::*;
|
||||
|
||||
mod signature;
|
||||
pub use signature::*;
|
||||
|
||||
mod config;
|
||||
pub use config::*;
|
||||
|
||||
mod url;
|
||||
pub use self::url::*;
|
@ -1,17 +0,0 @@
|
||||
pub struct Blob<'a>(git2::Blob<'a>);
|
||||
|
||||
impl<'a> From<git2::Blob<'a>> for Blob<'a> {
|
||||
fn from(value: git2::Blob<'a>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Blob<'_> {
|
||||
pub fn content(&self) -> &[u8] {
|
||||
self.0.content()
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.0.size()
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
use super::{Commit, Oid, Result, Tree};
|
||||
|
||||
pub struct Branch<'repo> {
|
||||
branch: git2::Branch<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::Branch<'repo>> for Branch<'repo> {
|
||||
fn from(branch: git2::Branch<'repo>) -> Self {
|
||||
Self { branch }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> Branch<'repo> {
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.branch.get().name()
|
||||
}
|
||||
|
||||
pub fn refname(&self) -> Option<&str> {
|
||||
self.branch.get().name()
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Option<Oid> {
|
||||
self.branch.get().target().map(Into::into)
|
||||
}
|
||||
|
||||
pub fn upstream(&self) -> Result<Branch<'repo>> {
|
||||
self.branch.upstream().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn refname_bytes(&self) -> &[u8] {
|
||||
self.branch.get().name_bytes()
|
||||
}
|
||||
|
||||
pub fn peel_to_tree(&self) -> Result<Tree<'repo>> {
|
||||
self.branch
|
||||
.get()
|
||||
.peel_to_tree()
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn peel_to_commit(&self) -> Result<Commit<'repo>> {
|
||||
self.branch
|
||||
.get()
|
||||
.peel_to_commit()
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn is_remote(&self) -> bool {
|
||||
self.branch.get().is_remote()
|
||||
}
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
use super::{Oid, Result, Signature, Tree};
|
||||
|
||||
pub struct Commit<'repo> {
|
||||
commit: git2::Commit<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::Commit<'repo>> for Commit<'repo> {
|
||||
fn from(commit: git2::Commit<'repo>) -> Self {
|
||||
Self { commit }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> From<&'repo git2::Commit<'repo>> for Commit<'repo> {
|
||||
fn from(commit: &'repo git2::Commit<'repo>) -> Self {
|
||||
Self {
|
||||
commit: commit.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> From<&'repo Commit<'repo>> for &'repo git2::Commit<'repo> {
|
||||
fn from(val: &'repo Commit<'repo>) -> Self {
|
||||
&val.commit
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> Commit<'repo> {
|
||||
pub fn id(&self) -> Oid {
|
||||
self.commit.id().into()
|
||||
}
|
||||
|
||||
pub fn parent_count(&self) -> usize {
|
||||
self.commit.parent_count()
|
||||
}
|
||||
|
||||
pub fn tree(&self) -> Result<Tree<'repo>> {
|
||||
self.commit.tree().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn tree_id(&self) -> Oid {
|
||||
self.commit.tree_id().into()
|
||||
}
|
||||
|
||||
pub fn parents(&self) -> Result<Vec<Commit<'repo>>> {
|
||||
let mut parents = vec![];
|
||||
for i in 0..self.parent_count() {
|
||||
parents.push(self.parent(i)?);
|
||||
}
|
||||
Ok(parents)
|
||||
}
|
||||
|
||||
pub fn parent(&self, n: usize) -> Result<Commit<'repo>> {
|
||||
self.commit.parent(n).map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn time(&self) -> git2::Time {
|
||||
self.commit.time()
|
||||
}
|
||||
|
||||
pub fn author(&self) -> Signature<'_> {
|
||||
self.commit.author().into()
|
||||
}
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
self.commit.message()
|
||||
}
|
||||
|
||||
pub fn committer(&self) -> Signature<'_> {
|
||||
self.commit.committer().into()
|
||||
}
|
||||
|
||||
pub fn raw_header(&self) -> Option<&str> {
|
||||
self.commit.raw_header()
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
use super::{Error, Result};
|
||||
|
||||
pub struct Config {
|
||||
config: git2::Config,
|
||||
}
|
||||
|
||||
impl From<git2::Config> for Config {
|
||||
fn from(config: git2::Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Config> for git2::Config {
|
||||
fn from(v: Config) -> Self {
|
||||
v.config
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn set_str(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
self.config.set_str(key, value).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_bool(&mut self, key: &str, value: bool) -> Result<()> {
|
||||
self.config.set_bool(key, value).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_multivar(&mut self, key: &str, regexp: &str, value: &str) -> Result<()> {
|
||||
self.config
|
||||
.set_multivar(key, regexp, value)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_string(&self, key: &str) -> Result<Option<String>> {
|
||||
match self.config.get_string(key).map_err(Into::into) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(Error::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bool(&self, key: &str) -> Result<Option<bool>> {
|
||||
match self.config.get_bool(key).map_err(Into::into) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(Error::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_local(&self, key: &str, val: &str) -> Result<()> {
|
||||
match self.config.open_level(git2::ConfigLevel::Local) {
|
||||
Ok(mut local) => local.set_str(key, val).map_err(Into::into),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_local(&self, key: &str) -> Result<Option<String>> {
|
||||
match self
|
||||
.config
|
||||
.open_level(git2::ConfigLevel::Local)
|
||||
.and_then(|local| local.get_string(key))
|
||||
{
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,392 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{keys, project_repository, projects, users};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SshCredential {
|
||||
Keyfile {
|
||||
key_path: PathBuf,
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
GitButlerKey(Box<keys::PrivateKey>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HttpsCredential {
|
||||
CredentialHelper { username: String, password: String },
|
||||
GitHubToken(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Credential {
|
||||
Noop,
|
||||
Ssh(SshCredential),
|
||||
Https(HttpsCredential),
|
||||
}
|
||||
|
||||
impl From<Credential> for git2::RemoteCallbacks<'_> {
|
||||
fn from(value: Credential) -> Self {
|
||||
let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
match value {
|
||||
Credential::Noop => {}
|
||||
Credential::Ssh(SshCredential::Keyfile {
|
||||
key_path,
|
||||
passphrase,
|
||||
}) => {
|
||||
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
|
||||
use resolve_path::PathResolveExt;
|
||||
let key_path = key_path.resolve();
|
||||
tracing::info!(
|
||||
"authenticating with {} using key {}",
|
||||
url,
|
||||
key_path.display()
|
||||
);
|
||||
git2::Cred::ssh_key("git", None, &key_path, passphrase.as_deref())
|
||||
});
|
||||
}
|
||||
Credential::Ssh(SshCredential::GitButlerKey(key)) => {
|
||||
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
|
||||
tracing::info!("authenticating with {} using gitbutler's key", url);
|
||||
git2::Cred::ssh_key_from_memory("git", None, &key.to_string(), None)
|
||||
});
|
||||
}
|
||||
Credential::Https(HttpsCredential::CredentialHelper { username, password }) => {
|
||||
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
|
||||
tracing::info!("authenticating with {url} as '{username}' with password using credential helper");
|
||||
git2::Cred::userpass_plaintext(&username, &password)
|
||||
});
|
||||
}
|
||||
Credential::Https(HttpsCredential::GitHubToken(token)) => {
|
||||
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
|
||||
tracing::info!("authenticating with {url} using github token");
|
||||
git2::Cred::userpass_plaintext("git", &token)
|
||||
});
|
||||
}
|
||||
};
|
||||
remote_callbacks
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Helper {
|
||||
keys: keys::Controller,
|
||||
users: users::Controller,
|
||||
home_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HelpError {
|
||||
#[error("no url set for remote")]
|
||||
NoUrlSet,
|
||||
#[error("failed to convert url: {0}")]
|
||||
UrlConvertError(#[from] super::ConvertError),
|
||||
#[error(transparent)]
|
||||
Users(#[from] users::GetError),
|
||||
#[error(transparent)]
|
||||
Key(#[from] keys::GetOrCreateError),
|
||||
#[error(transparent)]
|
||||
Git(#[from] super::Error),
|
||||
}
|
||||
|
||||
impl From<HelpError> for crate::error::Error {
|
||||
fn from(value: HelpError) -> Self {
|
||||
match value {
|
||||
HelpError::NoUrlSet => Self::UserError {
|
||||
code: crate::error::Code::ProjectGitRemote,
|
||||
message: "no url set for remote".to_string(),
|
||||
},
|
||||
HelpError::UrlConvertError(error) => Self::UserError {
|
||||
code: crate::error::Code::ProjectGitRemote,
|
||||
message: error.to_string(),
|
||||
},
|
||||
HelpError::Users(error) => error.into(),
|
||||
HelpError::Key(error) => error.into(),
|
||||
HelpError::Git(error) => {
|
||||
tracing::error!(?error, "failed to create auth credentials");
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Helper {
|
||||
pub fn new(
|
||||
keys: keys::Controller,
|
||||
users: users::Controller,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
keys,
|
||||
users,
|
||||
home_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self {
|
||||
let keys = keys::Controller::from_path(&path);
|
||||
let users = users::Controller::from_path(path);
|
||||
let home_dir = std::env::var_os("HOME").map(PathBuf::from);
|
||||
Self::new(keys, users, home_dir)
|
||||
}
|
||||
|
||||
pub fn help<'a>(
|
||||
&'a self,
|
||||
project_repository: &'a project_repository::Repository,
|
||||
remote_name: &str,
|
||||
) -> Result<Vec<(super::Remote, Vec<Credential>)>, HelpError> {
|
||||
let remote = project_repository.git_repository.find_remote(remote_name)?;
|
||||
let remote_url = remote.url()?.ok_or(HelpError::NoUrlSet)?;
|
||||
|
||||
// if file, no auth needed.
|
||||
if remote_url.scheme == super::Scheme::File {
|
||||
return Ok(vec![(remote, vec![Credential::Noop])]);
|
||||
}
|
||||
|
||||
match &project_repository.project().preferred_key {
|
||||
projects::AuthKey::Local { private_key_path } => {
|
||||
let ssh_remote = if remote_url.scheme == super::Scheme::Ssh {
|
||||
Ok(remote)
|
||||
} else {
|
||||
let ssh_url = remote_url.as_ssh()?;
|
||||
project_repository.git_repository.remote_anonymous(&ssh_url)
|
||||
}?;
|
||||
|
||||
Ok(vec![(
|
||||
ssh_remote,
|
||||
vec![Credential::Ssh(SshCredential::Keyfile {
|
||||
key_path: private_key_path.clone(),
|
||||
passphrase: None,
|
||||
})],
|
||||
)])
|
||||
}
|
||||
projects::AuthKey::GitCredentialsHelper => {
|
||||
let https_remote = if remote_url.scheme == super::Scheme::Https {
|
||||
Ok(remote)
|
||||
} else {
|
||||
let url = remote_url.as_https()?;
|
||||
project_repository.git_repository.remote_anonymous(&url)
|
||||
}?;
|
||||
let flow = Self::https_flow(project_repository, &remote_url)?
|
||||
.into_iter()
|
||||
.map(Credential::Https)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(vec![(https_remote, flow)])
|
||||
}
|
||||
projects::AuthKey::Generated => {
|
||||
let generated_flow = self.generated_flow(remote, project_repository)?;
|
||||
|
||||
let remote = project_repository.git_repository.find_remote(remote_name)?;
|
||||
let default_flow = self.default_flow(remote, project_repository)?;
|
||||
|
||||
Ok(vec![generated_flow, default_flow]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect())
|
||||
}
|
||||
projects::AuthKey::Default => self.default_flow(remote, project_repository),
|
||||
projects::AuthKey::SystemExecutable => {
|
||||
tracing::error!("WARNING: FIXME: this codepath should NEVER be hit. Something is seriously wrong.");
|
||||
self.default_flow(remote, project_repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_flow<'a>(
|
||||
&'a self,
|
||||
remote: super::Remote<'a>,
|
||||
project_repository: &'a project_repository::Repository,
|
||||
) -> Result<Vec<(super::Remote, Vec<Credential>)>, HelpError> {
|
||||
let remote_url = remote.url()?.ok_or(HelpError::NoUrlSet)?;
|
||||
|
||||
let ssh_remote = if remote_url.scheme == super::Scheme::Ssh {
|
||||
Ok(remote)
|
||||
} else {
|
||||
let ssh_url = remote_url.as_ssh()?;
|
||||
project_repository.git_repository.remote_anonymous(&ssh_url)
|
||||
}?;
|
||||
|
||||
let key = self.keys.get_or_create()?;
|
||||
Ok(vec![(
|
||||
ssh_remote,
|
||||
vec![Credential::Ssh(SshCredential::GitButlerKey(Box::new(key)))],
|
||||
)])
|
||||
}
|
||||
|
||||
fn default_flow<'a>(
|
||||
&'a self,
|
||||
remote: super::Remote<'a>,
|
||||
project_repository: &'a project_repository::Repository,
|
||||
) -> Result<Vec<(super::Remote, Vec<Credential>)>, HelpError> {
|
||||
let remote_url = remote.url()?.ok_or(HelpError::NoUrlSet)?;
|
||||
|
||||
// is github is authenticated, only try github.
|
||||
if remote_url.is_github() {
|
||||
if let Some(github_access_token) = self
|
||||
.users
|
||||
.get_user()?
|
||||
.and_then(|user| user.github_access_token)
|
||||
{
|
||||
let https_remote = if remote_url.scheme == super::Scheme::Https {
|
||||
Ok(remote)
|
||||
} else {
|
||||
let url = remote_url.as_https()?;
|
||||
project_repository.git_repository.remote_anonymous(&url)
|
||||
}?;
|
||||
return Ok(vec![(
|
||||
https_remote,
|
||||
vec![Credential::Https(HttpsCredential::GitHubToken(
|
||||
github_access_token,
|
||||
))],
|
||||
)]);
|
||||
}
|
||||
}
|
||||
|
||||
match remote_url.scheme {
|
||||
super::Scheme::Https => {
|
||||
let mut flow = vec![];
|
||||
|
||||
let https_flow = Self::https_flow(project_repository, &remote_url)?
|
||||
.into_iter()
|
||||
.map(Credential::Https)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !https_flow.is_empty() {
|
||||
flow.push((remote, https_flow));
|
||||
}
|
||||
|
||||
if let Ok(ssh_url) = remote_url.as_ssh() {
|
||||
let ssh_flow = self
|
||||
.ssh_flow()?
|
||||
.into_iter()
|
||||
.map(Credential::Ssh)
|
||||
.collect::<Vec<_>>();
|
||||
if !ssh_flow.is_empty() {
|
||||
flow.push((
|
||||
project_repository
|
||||
.git_repository
|
||||
.remote_anonymous(&ssh_url)?,
|
||||
ssh_flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(flow)
|
||||
}
|
||||
super::Scheme::Ssh => {
|
||||
let mut flow = vec![];
|
||||
|
||||
let ssh_flow = self
|
||||
.ssh_flow()?
|
||||
.into_iter()
|
||||
.map(Credential::Ssh)
|
||||
.collect::<Vec<_>>();
|
||||
if !ssh_flow.is_empty() {
|
||||
flow.push((remote, ssh_flow));
|
||||
}
|
||||
|
||||
if let Ok(https_url) = remote_url.as_https() {
|
||||
let https_flow = Self::https_flow(project_repository, &https_url)?
|
||||
.into_iter()
|
||||
.map(Credential::Https)
|
||||
.collect::<Vec<_>>();
|
||||
if !https_flow.is_empty() {
|
||||
flow.push((
|
||||
project_repository
|
||||
.git_repository
|
||||
.remote_anonymous(&https_url)?,
|
||||
https_flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(flow)
|
||||
}
|
||||
_ => {
|
||||
let mut flow = vec![];
|
||||
|
||||
if let Ok(https_url) = remote_url.as_https() {
|
||||
let https_flow = Self::https_flow(project_repository, &https_url)?
|
||||
.into_iter()
|
||||
.map(Credential::Https)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !https_flow.is_empty() {
|
||||
flow.push((
|
||||
project_repository
|
||||
.git_repository
|
||||
.remote_anonymous(&https_url)?,
|
||||
https_flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(ssh_url) = remote_url.as_ssh() {
|
||||
let ssh_flow = self
|
||||
.ssh_flow()?
|
||||
.into_iter()
|
||||
.map(Credential::Ssh)
|
||||
.collect::<Vec<_>>();
|
||||
if !ssh_flow.is_empty() {
|
||||
flow.push((
|
||||
project_repository
|
||||
.git_repository
|
||||
.remote_anonymous(&ssh_url)?,
|
||||
ssh_flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn https_flow(
|
||||
project_repository: &project_repository::Repository,
|
||||
remote_url: &super::Url,
|
||||
) -> Result<Vec<HttpsCredential>, HelpError> {
|
||||
let mut flow = vec![];
|
||||
|
||||
let mut helper = git2::CredentialHelper::new(&remote_url.to_string());
|
||||
let config = project_repository.git_repository.config()?;
|
||||
helper.config(&git2::Config::from(config));
|
||||
if let Some((username, password)) = helper.execute() {
|
||||
flow.push(HttpsCredential::CredentialHelper { username, password });
|
||||
}
|
||||
|
||||
Ok(flow)
|
||||
}
|
||||
|
||||
fn ssh_flow(&self) -> Result<Vec<SshCredential>, HelpError> {
|
||||
let mut flow = vec![];
|
||||
if let Some(home_path) = self.home_dir.as_ref() {
|
||||
let id_rsa_path = home_path.join(".ssh").join("id_rsa");
|
||||
if id_rsa_path.exists() {
|
||||
flow.push(SshCredential::Keyfile {
|
||||
key_path: id_rsa_path.clone(),
|
||||
passphrase: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id_ed25519_path = home_path.join(".ssh").join("id_ed25519");
|
||||
if id_ed25519_path.exists() {
|
||||
flow.push(SshCredential::Keyfile {
|
||||
key_path: id_ed25519_path.clone(),
|
||||
passphrase: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id_ecdsa_path = home_path.join(".ssh").join("id_ecdsa");
|
||||
if id_ecdsa_path.exists() {
|
||||
flow.push(SshCredential::Keyfile {
|
||||
key_path: id_ecdsa_path.clone(),
|
||||
passphrase: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let key = self.keys.get_or_create()?;
|
||||
flow.push(SshCredential::GitButlerKey(Box::new(key)));
|
||||
Ok(flow)
|
||||
}
|
||||
}
|
@ -1,421 +0,0 @@
|
||||
use std::{collections::HashMap, path, str};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::git;
|
||||
|
||||
use super::Repository;
|
||||
|
||||
/// The type of change
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ChangeType {
|
||||
/// Entry does not exist in old version
|
||||
Added,
|
||||
/// Entry does not exist in new version
|
||||
Deleted,
|
||||
/// Entry content changed between old and new
|
||||
Modified,
|
||||
}
|
||||
impl From<git2::Delta> for ChangeType {
|
||||
fn from(v: git2::Delta) -> Self {
|
||||
use git2::Delta as D;
|
||||
use ChangeType as C;
|
||||
match v {
|
||||
D::Untracked | D::Added => C::Added,
|
||||
D::Modified
|
||||
| D::Unmodified
|
||||
| D::Renamed
|
||||
| D::Copied
|
||||
| D::Typechange
|
||||
| D::Conflicted => C::Modified,
|
||||
D::Ignored | D::Unreadable | D::Deleted => C::Deleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
pub struct GitHunk {
|
||||
pub old_start: u32,
|
||||
pub old_lines: u32,
|
||||
pub new_start: u32,
|
||||
pub new_lines: u32,
|
||||
pub diff: String,
|
||||
pub binary: bool,
|
||||
pub change_type: ChangeType,
|
||||
}
|
||||
|
||||
impl GitHunk {
|
||||
pub fn contains(&self, line: u32) -> bool {
|
||||
self.new_start <= line && self.new_start + self.new_lines >= line
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub context_lines: u32,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { context_lines: 3 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileDiff {
|
||||
pub old_path: Option<path::PathBuf>,
|
||||
pub new_path: Option<path::PathBuf>,
|
||||
pub hunks: Option<Vec<GitHunk>>,
|
||||
pub skipped: bool,
|
||||
pub binary: bool,
|
||||
pub old_size_bytes: u64,
|
||||
pub new_size_bytes: u64,
|
||||
}
|
||||
|
||||
pub fn workdir(
|
||||
repository: &Repository,
|
||||
commit_oid: &git::Oid,
|
||||
context_lines: u32,
|
||||
) -> Result<HashMap<path::PathBuf, FileDiff>> {
|
||||
let commit = repository
|
||||
.find_commit(*commit_oid)
|
||||
.context("failed to find commit")?;
|
||||
let tree = commit.tree().context("failed to find tree")?;
|
||||
|
||||
let mut diff_opts = git2::DiffOptions::new();
|
||||
diff_opts
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_untracked(true)
|
||||
.show_binary(true)
|
||||
.show_untracked_content(true)
|
||||
.ignore_submodules(true)
|
||||
.context_lines(context_lines);
|
||||
|
||||
let mut diff = repository.diff_tree_to_workdir(Some(&tree), Some(&mut diff_opts))?;
|
||||
let (mut diff_opts, skipped_files) = without_large_files(50_000_000, &diff, diff_opts);
|
||||
if !skipped_files.is_empty() {
|
||||
diff = repository.diff_tree_to_workdir(Some(&tree), Some(&mut diff_opts))?;
|
||||
}
|
||||
let diff_files = hunks_by_filepath(repository, &diff);
|
||||
diff_files.map(|mut df| {
|
||||
for (key, value) in skipped_files {
|
||||
df.insert(key, value);
|
||||
}
|
||||
df
|
||||
})
|
||||
}
|
||||
|
||||
pub fn trees(
|
||||
repository: &Repository,
|
||||
old_tree: &git::Tree,
|
||||
new_tree: &git::Tree,
|
||||
context_lines: u32,
|
||||
) -> Result<HashMap<path::PathBuf, FileDiff>> {
|
||||
let mut diff_opts = git2::DiffOptions::new();
|
||||
diff_opts
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_untracked(true)
|
||||
.show_binary(true)
|
||||
.ignore_submodules(true)
|
||||
.context_lines(context_lines)
|
||||
.show_untracked_content(true);
|
||||
|
||||
let diff =
|
||||
repository.diff_tree_to_tree(Some(old_tree), Some(new_tree), Some(&mut diff_opts))?;
|
||||
|
||||
hunks_by_filepath(repository, &diff)
|
||||
}
|
||||
|
||||
pub fn without_large_files(
|
||||
size_limit_bytes: u64,
|
||||
diff: &git2::Diff,
|
||||
mut diff_opts: git2::DiffOptions,
|
||||
) -> (git2::DiffOptions, HashMap<path::PathBuf, FileDiff>) {
|
||||
let mut skipped_files: HashMap<path::PathBuf, FileDiff> = HashMap::new();
|
||||
for delta in diff.deltas() {
|
||||
if delta.new_file().size() > size_limit_bytes {
|
||||
if let Some(path) = delta.new_file().path() {
|
||||
skipped_files.insert(
|
||||
path.to_path_buf(),
|
||||
FileDiff {
|
||||
old_path: delta.old_file().path().map(std::path::Path::to_path_buf),
|
||||
new_path: delta.new_file().path().map(std::path::Path::to_path_buf),
|
||||
hunks: None,
|
||||
skipped: true,
|
||||
binary: true,
|
||||
old_size_bytes: delta.old_file().size(),
|
||||
new_size_bytes: delta.new_file().size(),
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if let Some(path) = delta.new_file().path() {
|
||||
if let Some(path) = path.to_str() {
|
||||
diff_opts.pathspec(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
(diff_opts, skipped_files)
|
||||
}
|
||||
|
||||
fn hunks_by_filepath(
|
||||
repository: &Repository,
|
||||
diff: &git2::Diff,
|
||||
) -> Result<HashMap<path::PathBuf, FileDiff>> {
|
||||
// find all the hunks
|
||||
let mut hunks_by_filepath: HashMap<path::PathBuf, Vec<GitHunk>> = HashMap::new();
|
||||
let mut diff_files: HashMap<path::PathBuf, FileDiff> = HashMap::new();
|
||||
|
||||
diff.print(
|
||||
git2::DiffFormat::Patch,
|
||||
|delta, hunk, line: git2::DiffLine<'_>| {
|
||||
let change_type: ChangeType = delta.status().into();
|
||||
let file_path = delta.new_file().path().unwrap_or_else(|| {
|
||||
delta
|
||||
.old_file()
|
||||
.path()
|
||||
.expect("failed to get file name from diff")
|
||||
});
|
||||
|
||||
hunks_by_filepath
|
||||
.entry(file_path.to_path_buf())
|
||||
.or_default();
|
||||
|
||||
let new_start = hunk.as_ref().map_or(0, git2::DiffHunk::new_start);
|
||||
let new_lines = hunk.as_ref().map_or(0, git2::DiffHunk::new_lines);
|
||||
let old_start = hunk.as_ref().map_or(0, git2::DiffHunk::old_start);
|
||||
let old_lines = hunk.as_ref().map_or(0, git2::DiffHunk::old_lines);
|
||||
|
||||
if let Some((line, is_binary)) = match line.origin() {
|
||||
'+' | '-' | ' ' => {
|
||||
if let Ok(content) = str::from_utf8(line.content()) {
|
||||
Some((format!("{}{}", line.origin(), content), false))
|
||||
} else {
|
||||
let full_path = repository.workdir().unwrap().join(file_path);
|
||||
// save the file_path to the odb
|
||||
if !delta.new_file().id().is_zero() && full_path.exists() {
|
||||
// the binary file wasnt deleted
|
||||
repository.blob_path(full_path.as_path()).unwrap();
|
||||
}
|
||||
Some((delta.new_file().id().to_string(), true))
|
||||
}
|
||||
}
|
||||
'B' => {
|
||||
let full_path = repository.workdir().unwrap().join(file_path);
|
||||
// save the file_path to the odb
|
||||
if !delta.new_file().id().is_zero() && full_path.exists() {
|
||||
// the binary file wasnt deleted
|
||||
repository.blob_path(full_path.as_path()).unwrap();
|
||||
}
|
||||
Some((delta.new_file().id().to_string(), true))
|
||||
}
|
||||
'F' => None,
|
||||
_ => {
|
||||
if let Ok(content) = str::from_utf8(line.content()) {
|
||||
Some((content.to_string(), false))
|
||||
} else {
|
||||
let full_path = repository.workdir().unwrap().join(file_path);
|
||||
// save the file_path to the odb
|
||||
if !delta.new_file().id().is_zero() && full_path.exists() {
|
||||
// the binary file wasnt deleted
|
||||
repository.blob_path(full_path.as_path()).unwrap();
|
||||
}
|
||||
Some((delta.new_file().id().to_string(), true))
|
||||
}
|
||||
}
|
||||
} {
|
||||
let hunks = hunks_by_filepath
|
||||
.entry(file_path.to_path_buf())
|
||||
.or_default();
|
||||
|
||||
if let Some(previous_hunk) = hunks.last_mut() {
|
||||
let hunk_did_not_change = previous_hunk.old_start == old_start
|
||||
&& previous_hunk.old_lines == old_lines
|
||||
&& previous_hunk.new_start == new_start
|
||||
&& previous_hunk.new_lines == new_lines;
|
||||
|
||||
if hunk_did_not_change {
|
||||
if is_binary {
|
||||
// binary overrides the diff
|
||||
previous_hunk.binary = true;
|
||||
previous_hunk.old_start = 0;
|
||||
previous_hunk.old_lines = 0;
|
||||
previous_hunk.new_start = 0;
|
||||
previous_hunk.new_lines = 0;
|
||||
previous_hunk.diff = line;
|
||||
} else if !previous_hunk.binary {
|
||||
// append non binary hunks
|
||||
previous_hunk.diff.push_str(&line);
|
||||
}
|
||||
} else {
|
||||
hunks.push(GitHunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
diff: line,
|
||||
binary: is_binary,
|
||||
change_type,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
hunks.push(GitHunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
diff: line,
|
||||
binary: is_binary,
|
||||
change_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
diff_files.insert(
|
||||
file_path.to_path_buf(),
|
||||
FileDiff {
|
||||
old_path: delta.old_file().path().map(std::path::Path::to_path_buf),
|
||||
new_path: delta.new_file().path().map(std::path::Path::to_path_buf),
|
||||
hunks: None,
|
||||
skipped: false,
|
||||
binary: delta.new_file().is_binary(),
|
||||
old_size_bytes: delta.old_file().size(),
|
||||
new_size_bytes: delta.new_file().size(),
|
||||
},
|
||||
);
|
||||
|
||||
true
|
||||
},
|
||||
)
|
||||
.context("failed to print diff")?;
|
||||
|
||||
let hunks_by_filepath: HashMap<path::PathBuf, Vec<GitHunk>> = hunks_by_filepath
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
if let Some(binary_hunk) = v.iter().find(|hunk| hunk.binary) {
|
||||
if v.len() > 1 {
|
||||
// if there are multiple hunks with binary among them, then the binary hunk
|
||||
// takes precedence
|
||||
(
|
||||
k,
|
||||
vec![GitHunk {
|
||||
old_start: 0,
|
||||
old_lines: 0,
|
||||
new_start: 0,
|
||||
new_lines: 0,
|
||||
diff: binary_hunk.diff.clone(),
|
||||
binary: true,
|
||||
change_type: binary_hunk.change_type,
|
||||
}],
|
||||
)
|
||||
} else {
|
||||
(k, v)
|
||||
}
|
||||
} else if v.is_empty() {
|
||||
// this is a new file
|
||||
(
|
||||
k,
|
||||
vec![GitHunk {
|
||||
old_start: 0,
|
||||
old_lines: 0,
|
||||
new_start: 0,
|
||||
new_lines: 0,
|
||||
diff: String::new(),
|
||||
binary: false,
|
||||
change_type: ChangeType::Modified,
|
||||
}],
|
||||
)
|
||||
} else {
|
||||
(k, v)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (file_path, diff_file) in &mut diff_files {
|
||||
diff_file.hunks = hunks_by_filepath.get(file_path).cloned();
|
||||
}
|
||||
Ok(diff_files)
|
||||
}
|
||||
|
||||
// returns None if cannot reverse the patch header
|
||||
fn reverse_patch_header(header: &str) -> Option<String> {
|
||||
use itertools::Itertools;
|
||||
|
||||
let mut parts = header.split_whitespace();
|
||||
|
||||
match parts.next() {
|
||||
Some("@@") => {}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let old_range = parts.next()?;
|
||||
let new_range = parts.next()?;
|
||||
|
||||
match parts.next() {
|
||||
Some("@@") => {}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"@@ {} {} @@ {}",
|
||||
new_range.replace('+', "-"),
|
||||
old_range.replace('-', "+"),
|
||||
parts.join(" ")
|
||||
))
|
||||
}
|
||||
|
||||
fn reverse_patch(patch: &str) -> Option<String> {
|
||||
let mut reversed = String::new();
|
||||
for line in patch.lines() {
|
||||
if line.starts_with("@@") {
|
||||
if let Some(header) = reverse_patch_header(line) {
|
||||
reversed.push_str(&header);
|
||||
reversed.push('\n');
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else if line.starts_with('+') {
|
||||
reversed.push_str(&line.replacen('+', "-", 1));
|
||||
reversed.push('\n');
|
||||
} else if line.starts_with('-') {
|
||||
reversed.push_str(&line.replacen('-', "+", 1));
|
||||
reversed.push('\n');
|
||||
} else {
|
||||
reversed.push_str(line);
|
||||
reversed.push('\n');
|
||||
}
|
||||
}
|
||||
Some(reversed)
|
||||
}
|
||||
|
||||
// returns None if cannot reverse the hunk
|
||||
pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
|
||||
if hunk.binary {
|
||||
None
|
||||
} else {
|
||||
reverse_patch(&hunk.diff).map(|diff| GitHunk {
|
||||
old_start: hunk.new_start,
|
||||
old_lines: hunk.new_lines,
|
||||
new_start: hunk.old_start,
|
||||
new_lines: hunk.old_lines,
|
||||
diff,
|
||||
binary: hunk.binary,
|
||||
change_type: hunk.change_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff_files_to_hunks(
|
||||
files: &HashMap<path::PathBuf, FileDiff>,
|
||||
) -> HashMap<path::PathBuf, Vec<git::diff::GitHunk>> {
|
||||
let mut file_hunks: HashMap<path::PathBuf, Vec<git::diff::GitHunk>> = HashMap::new();
|
||||
for (file_path, diff_file) in files {
|
||||
if !diff_file.skipped {
|
||||
file_hunks.insert(
|
||||
file_path.clone(),
|
||||
diff_file.hunks.clone().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
file_hunks
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
use std::str::Utf8Error;
|
||||
|
||||
use crate::keys;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(git2::Error),
|
||||
#[error("authentication failed")]
|
||||
Auth(git2::Error),
|
||||
#[error("sign error: {0}")]
|
||||
Signing(keys::SignError),
|
||||
#[error("remote url error: {0}")]
|
||||
Url(super::url::ParseError),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("network error: {0}")]
|
||||
Network(git2::Error),
|
||||
#[error("hook error: {0}")]
|
||||
Hooks(#[from] git2_hooks::HooksError),
|
||||
#[error("http error: {0}")]
|
||||
Http(git2::Error),
|
||||
#[error("checkout error: {0}")]
|
||||
Checkout(git2::Error),
|
||||
#[error(transparent)]
|
||||
Other(git2::Error),
|
||||
#[error(transparent)]
|
||||
Utf8(#[from] Utf8Error),
|
||||
}
|
||||
|
||||
impl From<git2::Error> for Error {
|
||||
fn from(err: git2::Error) -> Self {
|
||||
match err.class() {
|
||||
git2::ErrorClass::Ssh => match err.code() {
|
||||
git2::ErrorCode::GenericError | git2::ErrorCode::Auth => Error::Auth(err),
|
||||
_ => Error::Other(err),
|
||||
},
|
||||
git2::ErrorClass::Checkout => Error::Checkout(err),
|
||||
git2::ErrorClass::Http => Error::Http(err),
|
||||
git2::ErrorClass::Net => Error::Network(err),
|
||||
_ => match err.code() {
|
||||
git2::ErrorCode::NotFound => Error::NotFound(err),
|
||||
git2::ErrorCode::Auth => Error::Auth(err),
|
||||
_ => Error::Other(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<keys::SignError> for Error {
|
||||
fn from(err: keys::SignError) -> Self {
|
||||
Error::Signing(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::url::ParseError> for Error {
|
||||
fn from(err: super::url::ParseError) -> Self {
|
||||
Error::Url(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
@ -1,164 +0,0 @@
|
||||
use std::path;
|
||||
|
||||
use filetime::FileTime;
|
||||
|
||||
use super::{Error, Oid, Repository, Result, Tree};
|
||||
|
||||
pub struct Index {
|
||||
index: git2::Index,
|
||||
}
|
||||
|
||||
impl TryFrom<Tree<'_>> for Index {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: Tree<'_>) -> std::result::Result<Self, Self::Error> {
|
||||
Self::try_from(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Tree<'_>> for Index {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &Tree) -> Result<Self> {
|
||||
let mut empty_index = Self::new()?;
|
||||
empty_index.read_tree(value)?;
|
||||
Ok(empty_index)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut Index> for &'a mut git2::Index {
|
||||
fn from(index: &'a mut Index) -> Self {
|
||||
&mut index.index
|
||||
}
|
||||
}
|
||||
|
||||
impl From<git2::Index> for Index {
|
||||
fn from(index: git2::Index) -> Self {
|
||||
Self { index }
|
||||
}
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Index {
|
||||
index: git2::Index::new()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_all<I, T>(
|
||||
&mut self,
|
||||
pathspecs: I,
|
||||
flag: git2::IndexAddOption,
|
||||
cb: Option<&mut git2::IndexMatchedPath<'_>>,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: git2::IntoCString,
|
||||
I: IntoIterator<Item = T>,
|
||||
{
|
||||
self.index.add_all(pathspecs, flag, cb).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn conflicts(&self) -> Result<git2::IndexConflicts> {
|
||||
self.index.conflicts().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn read_tree(&mut self, tree: &Tree) -> Result<()> {
|
||||
self.index.read_tree(tree.into()).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn write_tree_to(&mut self, repo: &Repository) -> Result<Oid> {
|
||||
self.index
|
||||
.write_tree_to(repo.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn has_conflicts(&self) -> bool {
|
||||
self.index.has_conflicts()
|
||||
}
|
||||
|
||||
pub fn write_tree(&mut self) -> Result<Oid> {
|
||||
self.index.write_tree().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn add(&mut self, entry: &IndexEntry) -> Result<()> {
|
||||
self.index.add(&entry.clone().into()).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn write(&mut self) -> Result<()> {
|
||||
self.index.write().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn add_path(&mut self, path: &path::Path) -> Result<()> {
|
||||
self.index.add_path(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn remove_path(&mut self, path: &path::Path) -> Result<()> {
|
||||
self.index.remove_path(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_path(&self, path: &path::Path, stage: i32) -> Option<IndexEntry> {
|
||||
self.index.get_path(path, stage).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexEntry {
|
||||
pub ctime: FileTime,
|
||||
pub mtime: FileTime,
|
||||
pub dev: u32,
|
||||
pub ino: u32,
|
||||
pub mode: u32,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub file_size: u32,
|
||||
pub id: Oid,
|
||||
pub flags: u16,
|
||||
pub flags_extended: u16,
|
||||
pub path: Vec<u8>,
|
||||
}
|
||||
|
||||
impl From<git2::IndexEntry> for IndexEntry {
|
||||
fn from(value: git2::IndexEntry) -> Self {
|
||||
Self {
|
||||
ctime: FileTime::from_unix_time(
|
||||
i64::from(value.ctime.seconds()),
|
||||
value.ctime.nanoseconds(),
|
||||
),
|
||||
mtime: FileTime::from_unix_time(
|
||||
i64::from(value.mtime.seconds()),
|
||||
value.mtime.nanoseconds(),
|
||||
),
|
||||
dev: value.dev,
|
||||
ino: value.ino,
|
||||
mode: value.mode,
|
||||
uid: value.uid,
|
||||
gid: value.gid,
|
||||
file_size: value.file_size,
|
||||
id: value.id.into(),
|
||||
flags: value.flags,
|
||||
flags_extended: value.flags_extended,
|
||||
path: value.path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IndexEntry> for git2::IndexEntry {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
fn from(entry: IndexEntry) -> Self {
|
||||
Self {
|
||||
ctime: git2::IndexTime::new(entry.ctime.seconds() as i32, entry.ctime.nanoseconds()),
|
||||
mtime: git2::IndexTime::new(entry.mtime.seconds() as i32, entry.mtime.nanoseconds()),
|
||||
dev: entry.dev,
|
||||
ino: entry.ino,
|
||||
mode: entry.mode,
|
||||
uid: entry.uid,
|
||||
gid: entry.gid,
|
||||
file_size: entry.file_size,
|
||||
id: entry.id.into(),
|
||||
flags: entry.flags,
|
||||
flags_extended: entry.flags_extended,
|
||||
path: entry.path,
|
||||
}
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, PartialEq, Copy, Clone, Hash, Eq)]
|
||||
pub struct Oid {
|
||||
oid: git2::Oid,
|
||||
}
|
||||
|
||||
impl Default for Oid {
|
||||
fn default() -> Self {
|
||||
git2::Oid::zero().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Oid {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.oid.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Oid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
git2::Oid::from_str(&s)
|
||||
.map_err(|e| serde::de::Error::custom(format!("invalid oid: {}", e)))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Oid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.oid.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Oid {
|
||||
type Err = git2::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
git2::Oid::from_str(s).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<git2::Oid> for Oid {
|
||||
fn from(oid: git2::Oid) -> Self {
|
||||
Self { oid }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Oid> for git2::Oid {
|
||||
fn from(oid: Oid) -> Self {
|
||||
oid.oid
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
mod refname;
|
||||
pub use refname::{LocalRefname, Refname, RemoteRefname, VirtualRefname};
|
||||
|
||||
use super::{Commit, Oid, Result, Tree};
|
||||
|
||||
pub struct Reference<'repo> {
|
||||
reference: git2::Reference<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::Reference<'repo>> for Reference<'repo> {
|
||||
fn from(reference: git2::Reference<'repo>) -> Self {
|
||||
Reference { reference }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> Reference<'repo> {
|
||||
pub fn name(&self) -> Option<Refname> {
|
||||
self.reference
|
||||
.name()
|
||||
.map(|name| name.parse().expect("libgit2 provides valid refnames"))
|
||||
}
|
||||
|
||||
pub fn name_bytes(&self) -> &[u8] {
|
||||
self.reference.name_bytes()
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Option<Oid> {
|
||||
self.reference.target().map(Into::into)
|
||||
}
|
||||
|
||||
pub fn peel_to_commit(&self) -> Result<Commit<'repo>> {
|
||||
self.reference
|
||||
.peel_to_commit()
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn peel_to_tree(&self) -> Result<Tree<'repo>> {
|
||||
self.reference
|
||||
.peel_to_tree()
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn rename(
|
||||
&mut self,
|
||||
new_name: &Refname,
|
||||
force: bool,
|
||||
log_message: &str,
|
||||
) -> Result<Reference<'repo>> {
|
||||
self.reference
|
||||
.rename(&new_name.to_string(), force, log_message)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete(&mut self) -> Result<()> {
|
||||
self.reference.delete().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn is_remote(&self) -> bool {
|
||||
self.reference.is_remote()
|
||||
}
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
mod error;
|
||||
mod local;
|
||||
mod remote;
|
||||
mod r#virtual;
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use error::Error;
|
||||
pub use local::Refname as LocalRefname;
|
||||
pub use r#virtual::Refname as VirtualRefname;
|
||||
pub use remote::Refname as RemoteRefname;
|
||||
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Refname {
|
||||
Other(String),
|
||||
Remote(RemoteRefname),
|
||||
Local(LocalRefname),
|
||||
Virtual(VirtualRefname),
|
||||
}
|
||||
|
||||
impl From<&RemoteRefname> for Refname {
|
||||
fn from(value: &RemoteRefname) -> Self {
|
||||
Self::Remote(value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemoteRefname> for Refname {
|
||||
fn from(value: RemoteRefname) -> Self {
|
||||
Self::Remote(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VirtualRefname> for Refname {
|
||||
fn from(value: VirtualRefname) -> Self {
|
||||
Self::Virtual(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&VirtualRefname> for Refname {
|
||||
fn from(value: &VirtualRefname) -> Self {
|
||||
Self::Virtual(value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LocalRefname> for Refname {
|
||||
fn from(value: LocalRefname) -> Self {
|
||||
Self::Local(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LocalRefname> for Refname {
|
||||
fn from(value: &LocalRefname) -> Self {
|
||||
Self::Local(value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Refname {
|
||||
pub fn branch(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Other(_) => None,
|
||||
Self::Remote(remote) => Some(remote.branch()),
|
||||
Self::Local(local) => Some(local.branch()),
|
||||
Self::Virtual(r#virtual) => Some(r#virtual.branch()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simple_name(&self) -> String {
|
||||
match self {
|
||||
Refname::Virtual(virtual_refname) => virtual_refname.branch().to_string(),
|
||||
Refname::Local(local) => local.branch().to_string(),
|
||||
Refname::Remote(remote) => {
|
||||
format!("{}/{}", remote.remote(), remote.branch())
|
||||
}
|
||||
Refname::Other(raw) => raw.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Refname {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
value if value.starts_with("refs/remotes/") => Ok(Self::Remote(value.parse()?)),
|
||||
value if value.starts_with("refs/heads/") => Ok(Self::Local(value.parse()?)),
|
||||
value if value.starts_with("refs/gitbutler/") => Ok(Self::Virtual(value.parse()?)),
|
||||
"HEAD" => Ok(Self::Other(value.to_string())),
|
||||
value if value.starts_with("refs/") => Ok(Self::Other(value.to_string())),
|
||||
_ => Err(Error::InvalidName(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&git::Branch<'_>> for Refname {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &git::Branch<'_>) -> std::result::Result<Self, Self::Error> {
|
||||
if value.is_remote() {
|
||||
Ok(Self::Remote(RemoteRefname::try_from(value)?))
|
||||
} else {
|
||||
Ok(Self::Local(LocalRefname::try_from(value)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Refname {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Other(raw) => raw.fmt(f),
|
||||
Self::Remote(remote) => remote.fmt(f),
|
||||
Self::Local(local) => local.fmt(f),
|
||||
Self::Virtual(r#virtual) => r#virtual.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Refname {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Self::Other(raw) => raw.serialize(serializer),
|
||||
Self::Remote(remote) => remote.serialize(serializer),
|
||||
Self::Local(local) => local.serialize(serializer),
|
||||
Self::Virtual(r#virtual) => r#virtual.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'d> Deserialize<'d> for Refname {
|
||||
fn deserialize<D: serde::Deserializer<'d>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
name.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("branch name is invalid: {0}")]
|
||||
InvalidName(String),
|
||||
#[error("reference is not a tag: {0}")]
|
||||
NotTag(String),
|
||||
#[error("branch is not local: {0}")]
|
||||
NotLocal(String),
|
||||
#[error("branch is not remote: {0}")]
|
||||
NotRemote(String),
|
||||
#[error(transparent)]
|
||||
Git(#[from] git::Error),
|
||||
#[error(transparent)]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::git;
|
||||
|
||||
use super::{error::Error, remote};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Refname {
|
||||
// contains name of the branch, e.x. "master" or "main"
|
||||
branch: String,
|
||||
// contains name of the remote branch, if the local branch is tracking a remote branch
|
||||
remote: Option<remote::Refname>,
|
||||
}
|
||||
|
||||
impl Refname {
|
||||
pub fn new(branch: &str, remote: Option<remote::Refname>) -> Self {
|
||||
Self {
|
||||
branch: branch.to_string(),
|
||||
remote,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn branch(&self) -> &str {
|
||||
&self.branch
|
||||
}
|
||||
|
||||
pub fn remote(&self) -> Option<&remote::Refname> {
|
||||
self.remote.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Refname {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'d> Deserialize<'d> for Refname {
|
||||
fn deserialize<D: serde::Deserializer<'d>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
name.as_str().parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Refname {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "refs/heads/{}", self.branch)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Refname {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if !value.starts_with("refs/heads/") {
|
||||
return Err(Error::NotLocal(value.to_string()));
|
||||
}
|
||||
|
||||
if let Some(branch) = value.strip_prefix("refs/heads/") {
|
||||
Ok(Self {
|
||||
branch: branch.to_string(),
|
||||
remote: None,
|
||||
})
|
||||
} else {
|
||||
Err(Error::InvalidName(value.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&git::Branch<'_>> for Refname {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &git::Branch<'_>) -> std::result::Result<Self, Self::Error> {
|
||||
let branch_name = String::from_utf8(value.refname_bytes().to_vec()).map_err(Error::Utf8)?;
|
||||
if value.is_remote() {
|
||||
Err(Error::NotLocal(branch_name))
|
||||
} else {
|
||||
let branch: Self = branch_name.parse()?;
|
||||
match value.upstream() {
|
||||
Ok(upstream) => Ok(Self {
|
||||
remote: Some(remote::Refname::try_from(&upstream)?),
|
||||
..branch
|
||||
}),
|
||||
Err(git::Error::NotFound(_)) => Ok(Self {
|
||||
remote: None,
|
||||
..branch
|
||||
}),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::git;
|
||||
|
||||
use super::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub struct Refname {
|
||||
// contains name of the remote, e.x. "origin" or "upstream"
|
||||
remote: String,
|
||||
// contains name of the branch, e.x. "master" or "main"
|
||||
branch: String,
|
||||
}
|
||||
|
||||
impl Refname {
|
||||
pub fn new(remote: &str, branch: &str) -> Self {
|
||||
Self {
|
||||
remote: remote.to_string(),
|
||||
branch: branch.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_branch(&self, branch: &str) -> Self {
|
||||
Self {
|
||||
branch: branch.to_string(),
|
||||
remote: self.remote.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn branch(&self) -> &str {
|
||||
&self.branch
|
||||
}
|
||||
|
||||
pub fn remote(&self) -> &str {
|
||||
&self.remote
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Refname {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "refs/remotes/{}/{}", self.remote, self.branch)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Refname {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'d> Deserialize<'d> for Refname {
|
||||
fn deserialize<D: serde::Deserializer<'d>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
name.as_str().parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Refname {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if !value.starts_with("refs/remotes/") {
|
||||
return Err(Error::NotRemote(value.to_string()));
|
||||
};
|
||||
|
||||
let value = value.strip_prefix("refs/remotes/").unwrap();
|
||||
|
||||
if let Some((remote, branch)) = value.split_once('/') {
|
||||
Ok(Self {
|
||||
remote: remote.to_string(),
|
||||
branch: branch.to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(Error::InvalidName(value.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&git::Branch<'_>> for Refname {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &git::Branch<'_>) -> std::result::Result<Self, Self::Error> {
|
||||
let refname = String::from_utf8(value.refname_bytes().to_vec()).map_err(Error::Utf8)?;
|
||||
|
||||
if !value.is_remote() {
|
||||
return Err(Error::NotRemote(refname));
|
||||
}
|
||||
|
||||
refname.parse()
|
||||
}
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::virtual_branches::normalize_branch_name;
|
||||
use crate::virtual_branches::Branch;
|
||||
|
||||
use super::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Refname {
|
||||
// contains slug of the virtual branch name
|
||||
branch: String,
|
||||
}
|
||||
|
||||
impl Refname {
|
||||
pub fn branch(&self) -> &str {
|
||||
&self.branch
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Branch> for Refname {
|
||||
fn from(value: &Branch) -> Self {
|
||||
Self {
|
||||
branch: normalize_branch_name(&value.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Refname {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'d> Deserialize<'d> for Refname {
|
||||
fn deserialize<D: serde::Deserializer<'d>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
name.as_str().parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Refname {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "refs/gitbutler/{}", self.branch)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Refname {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if !value.starts_with("refs/gitbutler/") {
|
||||
return Err(Error::NotLocal(value.to_string()));
|
||||
}
|
||||
|
||||
if let Some(branch) = value.strip_prefix("refs/gitbutler/") {
|
||||
Ok(Self {
|
||||
branch: branch.to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(Error::InvalidName(value.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::{Result, Url};
|
||||
|
||||
pub struct Remote<'repo> {
|
||||
inner: git2::Remote<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::Remote<'repo>> for Remote<'repo> {
|
||||
fn from(inner: git2::Remote<'repo>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> Remote<'repo> {
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Result<Option<Url>> {
|
||||
self.inner
|
||||
.url()
|
||||
.map(FromStr::from_str)
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
refspec: &[&str],
|
||||
opts: Option<&mut git2::PushOptions<'_>>,
|
||||
) -> Result<()> {
|
||||
self.inner.push(refspec, opts).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn fetch(
|
||||
&mut self,
|
||||
refspec: &[&str],
|
||||
opts: Option<&mut git2::FetchOptions<'_>>,
|
||||
) -> Result<()> {
|
||||
self.inner.fetch(refspec, opts, None).map_err(Into::into)
|
||||
}
|
||||
}
|
@ -1,535 +0,0 @@
|
||||
use std::{io::Write, path::Path, str};
|
||||
|
||||
use git2::Submodule;
|
||||
use git2_hooks::HookResult;
|
||||
|
||||
use crate::{keys, path::Normalize};
|
||||
|
||||
use super::{
|
||||
Blob, Branch, Commit, Config, Index, Oid, Reference, Refname, Remote, Result, Signature, Tree,
|
||||
TreeBuilder, Url,
|
||||
};
|
||||
|
||||
// wrapper around git2::Repository to get control over how it's used.
|
||||
pub struct Repository(git2::Repository);
|
||||
|
||||
impl<'a> From<&'a Repository> for &'a git2::Repository {
|
||||
fn from(repo: &'a Repository) -> Self {
|
||||
&repo.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<git2::Repository> for Repository {
|
||||
fn from(repo: git2::Repository) -> Self {
|
||||
Self(repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
pub fn init<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let inner = git2::Repository::init(path)?;
|
||||
Ok(Repository(inner))
|
||||
}
|
||||
|
||||
pub fn init_opts<P: AsRef<Path>>(path: P, opts: &git2::RepositoryInitOptions) -> Result<Self> {
|
||||
let inner = git2::Repository::init_opts(path, opts)?;
|
||||
Ok(Repository(inner))
|
||||
}
|
||||
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let inner = git2::Repository::open(path)?;
|
||||
Ok(Repository(inner))
|
||||
}
|
||||
|
||||
pub fn add_disk_alternate<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let alternates_path = self.0.path().join("objects/info/alternates");
|
||||
if !alternates_path.exists() {
|
||||
let path = path.as_ref().normalize();
|
||||
let mut alternates_file = std::fs::File::create(&alternates_path)?;
|
||||
alternates_file.write_all(path.as_path().as_os_str().as_encoded_bytes())?;
|
||||
alternates_file.write_all(b"\n")?;
|
||||
self.0.odb().and_then(|odb| odb.refresh())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_submodule<P: AsRef<Path>>(&self, url: &Url, path: P) -> Result<Submodule<'_>> {
|
||||
self.0
|
||||
.submodule(&url.to_string(), path.as_ref(), false)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn rebase(
|
||||
&self,
|
||||
branch_oid: Option<Oid>,
|
||||
upstream_oid: Option<Oid>,
|
||||
onto_oid: Option<Oid>,
|
||||
opts: Option<&mut git2::RebaseOptions<'_>>,
|
||||
) -> Result<git2::Rebase<'_>> {
|
||||
let annotated_branch = if let Some(branch) = branch_oid {
|
||||
Some(self.0.find_annotated_commit(branch.into())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let annotated_upstream = if let Some(upstream) = upstream_oid {
|
||||
Some(self.0.find_annotated_commit(upstream.into())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let annotated_onto = if let Some(onto) = onto_oid {
|
||||
Some(self.0.find_annotated_commit(onto.into())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.0
|
||||
.rebase(
|
||||
annotated_branch.as_ref(),
|
||||
annotated_upstream.as_ref(),
|
||||
annotated_onto.as_ref(),
|
||||
opts,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn is_descendant_of(&self, a: Oid, b: Oid) -> Result<bool> {
|
||||
self.0
|
||||
.graph_descendant_of(a.into(), b.into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
|
||||
self.0
|
||||
.merge_base(one.into(), two.into())
|
||||
.map(Oid::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn merge_trees(
|
||||
&self,
|
||||
ancestor_tree: &Tree<'_>,
|
||||
our_tree: &Tree<'_>,
|
||||
their_tree: &Tree<'_>,
|
||||
) -> Result<Index> {
|
||||
self.0
|
||||
.merge_trees(
|
||||
ancestor_tree.into(),
|
||||
our_tree.into(),
|
||||
their_tree.into(),
|
||||
None,
|
||||
)
|
||||
.map(Index::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn diff_tree_to_tree(
|
||||
&self,
|
||||
old_tree: Option<&Tree<'_>>,
|
||||
new_tree: Option<&Tree<'_>>,
|
||||
opts: Option<&mut git2::DiffOptions>,
|
||||
) -> Result<git2::Diff<'_>> {
|
||||
self.0
|
||||
.diff_tree_to_tree(old_tree.map(Into::into), new_tree.map(Into::into), opts)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn diff_tree_to_workdir(
|
||||
&self,
|
||||
old_tree: Option<&Tree<'_>>,
|
||||
opts: Option<&mut git2::DiffOptions>,
|
||||
) -> Result<git2::Diff<'_>> {
|
||||
if let Ok(mut index) = self.0.index() {
|
||||
index.update_all(vec!["*"], None)?;
|
||||
}
|
||||
self.0
|
||||
.diff_tree_to_workdir_with_index(old_tree.map(Into::into), opts)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn reset(
|
||||
&self,
|
||||
commit: &Commit<'_>,
|
||||
kind: git2::ResetType,
|
||||
checkout: Option<&mut git2::build::CheckoutBuilder<'_>>,
|
||||
) -> Result<()> {
|
||||
let commit: &git2::Commit = commit.into();
|
||||
self.0
|
||||
.reset(commit.as_object(), kind, checkout)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_reference(&self, name: &Refname) -> Result<Reference> {
|
||||
self.0
|
||||
.find_reference(&name.to_string())
|
||||
.map(Reference::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn head(&self) -> Result<Reference> {
|
||||
self.0.head().map(Reference::from).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_tree(&self, id: Oid) -> Result<Tree> {
|
||||
self.0
|
||||
.find_tree(id.into())
|
||||
.map(Tree::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_commit(&self, id: Oid) -> Result<Commit> {
|
||||
self.0
|
||||
.find_commit(id.into())
|
||||
.map(Commit::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_blob(&self, id: Oid) -> Result<Blob> {
|
||||
self.0
|
||||
.find_blob(id.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn revwalk(&self) -> Result<git2::Revwalk> {
|
||||
self.0.revwalk().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn is_path_ignored<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
|
||||
self.0.is_path_ignored(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn branches(
|
||||
&self,
|
||||
filter: Option<git2::BranchType>,
|
||||
) -> Result<impl Iterator<Item = Result<(Branch, git2::BranchType)>>> {
|
||||
self.0
|
||||
.branches(filter)
|
||||
.map(|branches| {
|
||||
branches.map(|branch| {
|
||||
branch
|
||||
.map(|(branch, branch_type)| (Branch::from(branch), branch_type))
|
||||
.map_err(Into::into)
|
||||
})
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn index(&self) -> Result<Index> {
|
||||
self.0.index().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn index_size(&self) -> Result<usize> {
|
||||
Ok(self.0.index()?.len())
|
||||
}
|
||||
|
||||
pub fn blob_path<P: AsRef<Path>>(&self, path: P) -> Result<Oid> {
|
||||
self.0
|
||||
.blob_path(path.as_ref())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn cherry_pick(&self, base: &Commit, target: &Commit) -> Result<Index> {
|
||||
self.0
|
||||
.cherrypick_commit(target.into(), base.into(), 0, None)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn blob(&self, data: &[u8]) -> Result<Oid> {
|
||||
self.0.blob(data).map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn commit(
|
||||
&self,
|
||||
update_ref: Option<&Refname>,
|
||||
author: &Signature<'_>,
|
||||
committer: &Signature<'_>,
|
||||
message: &str,
|
||||
tree: &Tree<'_>,
|
||||
parents: &[&Commit<'_>],
|
||||
) -> Result<Oid> {
|
||||
let parents: Vec<&git2::Commit> = parents
|
||||
.iter()
|
||||
.map(|c| c.to_owned().into())
|
||||
.collect::<Vec<_>>();
|
||||
self.0
|
||||
.commit(
|
||||
update_ref.map(ToString::to_string).as_deref(),
|
||||
author.into(),
|
||||
committer.into(),
|
||||
message,
|
||||
tree.into(),
|
||||
&parents,
|
||||
)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn commit_signed(
|
||||
&self,
|
||||
author: &Signature<'_>,
|
||||
message: &str,
|
||||
tree: &Tree<'_>,
|
||||
parents: &[&Commit<'_>],
|
||||
key: &keys::PrivateKey,
|
||||
) -> Result<Oid> {
|
||||
let parents: Vec<&git2::Commit> = parents
|
||||
.iter()
|
||||
.map(|c| c.to_owned().into())
|
||||
.collect::<Vec<_>>();
|
||||
let commit_buffer = self.0.commit_create_buffer(
|
||||
author.into(),
|
||||
// author and committer must be the same
|
||||
// for signed commits
|
||||
author.into(),
|
||||
message,
|
||||
tree.into(),
|
||||
&parents,
|
||||
)?;
|
||||
let commit_buffer = str::from_utf8(&commit_buffer).unwrap();
|
||||
let signature = key.sign(commit_buffer.as_bytes())?;
|
||||
self.0
|
||||
.commit_signed(commit_buffer, &signature, None)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn config(&self) -> Result<Config> {
|
||||
self.0.config().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn treebuilder<'repo>(&'repo self, tree: Option<&'repo Tree>) -> TreeBuilder<'repo> {
|
||||
TreeBuilder::new(self, tree)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
|
||||
pub fn workdir(&self) -> Option<&Path> {
|
||||
self.0.workdir()
|
||||
}
|
||||
|
||||
pub fn branch_upstream_name(&self, branch_name: &str) -> Result<String> {
|
||||
self.0
|
||||
.branch_upstream_name(branch_name)
|
||||
.map(|s| s.as_str().unwrap().to_string())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn branch_remote_name(&self, refname: &str) -> Result<String> {
|
||||
self.0
|
||||
.branch_remote_name(refname)
|
||||
.map(|s| s.as_str().unwrap().to_string())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn branch_upstream_remote(&self, branch_name: &str) -> Result<String> {
|
||||
self.0
|
||||
.branch_upstream_remote(branch_name)
|
||||
.map(|s| s.as_str().unwrap().to_string())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn statuses(
|
||||
&self,
|
||||
options: Option<&mut git2::StatusOptions>,
|
||||
) -> Result<git2::Statuses<'_>> {
|
||||
self.0.statuses(options).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn remote_anonymous(&self, url: &super::Url) -> Result<Remote> {
|
||||
self.0
|
||||
.remote_anonymous(&url.to_string())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_remote(&self, name: &str) -> Result<Remote> {
|
||||
self.0.find_remote(name).map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn find_branch(&self, name: &Refname) -> Result<Branch> {
|
||||
self.0
|
||||
.find_branch(
|
||||
&name.simple_name(),
|
||||
match name {
|
||||
Refname::Virtual(_) | Refname::Local(_) | Refname::Other(_) => {
|
||||
git2::BranchType::Local
|
||||
}
|
||||
Refname::Remote(_) => git2::BranchType::Remote,
|
||||
},
|
||||
)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn refname_to_id(&self, name: &str) -> Result<Oid> {
|
||||
self.0
|
||||
.refname_to_id(name)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn checkout_head(&self, opts: Option<&mut git2::build::CheckoutBuilder>) -> Result<()> {
|
||||
self.0.checkout_head(opts).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn checkout_index<'a>(&'a self, index: &'a mut Index) -> CheckoutIndexBuilder {
|
||||
CheckoutIndexBuilder {
|
||||
index: index.into(),
|
||||
repo: &self.0,
|
||||
checkout_builder: git2::build::CheckoutBuilder::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkout_index_path<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let mut builder = git2::build::CheckoutBuilder::new();
|
||||
builder.path(path.as_ref());
|
||||
builder.force();
|
||||
|
||||
let mut index = self.0.index()?;
|
||||
self.0
|
||||
.checkout_index(Some(&mut index), Some(&mut builder))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn checkout_tree<'a>(&'a self, tree: &'a Tree<'a>) -> CheckoutTreeBuidler {
|
||||
CheckoutTreeBuidler {
|
||||
tree: tree.into(),
|
||||
repo: &self.0,
|
||||
checkout_builder: git2::build::CheckoutBuilder::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_head(&self, refname: &Refname) -> Result<()> {
|
||||
self.0.set_head(&refname.to_string()).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_head_detached(&self, commitish: Oid) -> Result<()> {
|
||||
self.0
|
||||
.set_head_detached(commitish.into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn branch(&self, name: &Refname, target: &Commit, force: bool) -> Result<Branch> {
|
||||
self.0
|
||||
.branch(&name.to_string(), target.into(), force)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn reference(
|
||||
&self,
|
||||
name: &Refname,
|
||||
id: Oid,
|
||||
force: bool,
|
||||
log_message: &str,
|
||||
) -> Result<Reference> {
|
||||
self.0
|
||||
.reference(&name.to_string(), id.into(), force, log_message)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_wd_tree(&self) -> Result<Tree> {
|
||||
let mut index = self.0.index()?;
|
||||
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
|
||||
let oid = index.write_tree()?;
|
||||
self.0.find_tree(oid).map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn remote(&self, name: &str, url: &Url) -> Result<Remote> {
|
||||
self.0
|
||||
.remote(name, &url.to_string())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn references(&self) -> Result<impl Iterator<Item = Result<Reference>>> {
|
||||
self.0
|
||||
.references()
|
||||
.map(|iter| iter.map(|reference| reference.map(Into::into).map_err(Into::into)))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn references_glob(&self, glob: &str) -> Result<impl Iterator<Item = Result<Reference>>> {
|
||||
self.0
|
||||
.references_glob(glob)
|
||||
.map(|iter| iter.map(|reference| reference.map(Into::into).map_err(Into::into)))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn run_hook_pre_commit(&self) -> Result<HookResult> {
|
||||
let res = git2_hooks::hooks_pre_commit(&self.0, Some(&["../.husky"]))?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn run_hook_commit_msg(&self, msg: &mut String) -> Result<HookResult> {
|
||||
let res = git2_hooks::hooks_commit_msg(&self.0, Some(&["../.husky"]), msg)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn run_hook_post_commit(&self) -> Result<()> {
|
||||
git2_hooks::hooks_post_commit(&self.0, Some(&["../.husky"]))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckoutTreeBuidler<'a> {
|
||||
repo: &'a git2::Repository,
|
||||
tree: &'a git2::Tree<'a>,
|
||||
checkout_builder: git2::build::CheckoutBuilder<'a>,
|
||||
}
|
||||
|
||||
impl CheckoutTreeBuidler<'_> {
|
||||
pub fn force(&mut self) -> &mut Self {
|
||||
self.checkout_builder.force();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn remove_untracked(&mut self) -> &mut Self {
|
||||
self.checkout_builder.remove_untracked(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checkout(&mut self) -> Result<()> {
|
||||
self.repo
|
||||
.checkout_tree(self.tree.as_object(), Some(&mut self.checkout_builder))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckoutIndexBuilder<'a> {
|
||||
repo: &'a git2::Repository,
|
||||
index: &'a mut git2::Index,
|
||||
checkout_builder: git2::build::CheckoutBuilder<'a>,
|
||||
}
|
||||
|
||||
impl CheckoutIndexBuilder<'_> {
|
||||
pub fn force(&mut self) -> &mut Self {
|
||||
self.checkout_builder.force();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn allow_conflicts(&mut self) -> &mut Self {
|
||||
self.checkout_builder.allow_conflicts(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn conflict_style_merge(&mut self) -> &mut Self {
|
||||
self.checkout_builder.conflict_style_merge(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checkout(&mut self) -> Result<()> {
|
||||
self.repo
|
||||
.checkout_index(Some(&mut self.index), Some(&mut self.checkout_builder))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
use super::Repository;
|
||||
use crate::git;
|
||||
use std::{path, str};
|
||||
|
||||
use super::Result;
|
||||
|
||||
pub fn show_file_at_tree<P: AsRef<path::Path>>(
|
||||
repository: &Repository,
|
||||
file_path: P,
|
||||
tree: &git::Tree,
|
||||
) -> Result<String> {
|
||||
let file_path = file_path.as_ref();
|
||||
match tree.get_path(file_path) {
|
||||
Ok(tree_entry) => {
|
||||
let blob = repository.find_blob(tree_entry.id())?;
|
||||
let content = str::from_utf8(blob.content())?;
|
||||
Ok(content.to_string())
|
||||
}
|
||||
// If a file was introduced in this commit, the content in the parent tree is the empty string
|
||||
Err(_) => Ok(String::new()),
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
use crate::users;
|
||||
|
||||
pub struct Signature<'a> {
|
||||
signature: git2::Signature<'a>,
|
||||
}
|
||||
|
||||
impl Clone for Signature<'static> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
signature: self.signature.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Signature<'a>> for git2::Signature<'a> {
|
||||
fn from(value: Signature<'a>) -> Self {
|
||||
value.signature
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Signature<'a>> for &'a git2::Signature<'a> {
|
||||
fn from(value: &'a Signature<'a>) -> Self {
|
||||
&value.signature
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<git2::Signature<'a>> for Signature<'a> {
|
||||
fn from(value: git2::Signature<'a>) -> Self {
|
||||
Self { signature: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&users::User> for Signature<'_> {
|
||||
type Error = super::Error;
|
||||
|
||||
fn try_from(value: &users::User) -> Result<Self, Self::Error> {
|
||||
if let Some(name) = &value.name {
|
||||
git2::Signature::now(name, &value.email)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
} else if let Some(name) = &value.given_name {
|
||||
git2::Signature::now(name, &value.email)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
} else {
|
||||
git2::Signature::now(&value.email, &value.email)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Signature<'_> {
|
||||
pub fn now(name: &str, email: &str) -> Result<Self, super::Error> {
|
||||
git2::Signature::now(name, email)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.signature.name()
|
||||
}
|
||||
|
||||
pub fn email(&self) -> Option<&str> {
|
||||
self.signature.email()
|
||||
}
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::{Oid, Repository, Result};
|
||||
use crate::path::Normalize;
|
||||
|
||||
pub struct Tree<'repo> {
|
||||
tree: git2::Tree<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::Tree<'repo>> for Tree<'repo> {
|
||||
fn from(tree: git2::Tree<'repo>) -> Self {
|
||||
Tree { tree }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> From<&'repo Tree<'repo>> for &'repo git2::Tree<'repo> {
|
||||
fn from(tree: &'repo Tree<'repo>) -> Self {
|
||||
&tree.tree
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> Tree<'repo> {
|
||||
pub fn id(&self) -> Oid {
|
||||
self.tree.id().into()
|
||||
}
|
||||
|
||||
pub fn get_path<P: AsRef<Path>>(&self, path: P) -> Result<TreeEntry<'repo>> {
|
||||
self.tree
|
||||
.get_path(path.normalize().as_path())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn walk<C>(&self, mut callback: C) -> Result<()>
|
||||
where
|
||||
C: FnMut(&str, &TreeEntry) -> TreeWalkResult,
|
||||
{
|
||||
self.tree
|
||||
.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
|
||||
match callback(root, &entry.clone().into()) {
|
||||
TreeWalkResult::Continue => git2::TreeWalkResult::Ok,
|
||||
TreeWalkResult::Skip => git2::TreeWalkResult::Skip,
|
||||
TreeWalkResult::Stop => git2::TreeWalkResult::Abort,
|
||||
}
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_name(&self, filename: &str) -> Option<TreeEntry> {
|
||||
self.tree.get_name(filename).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TreeWalkResult {
|
||||
Continue,
|
||||
Skip,
|
||||
Stop,
|
||||
}
|
||||
|
||||
pub struct TreeEntry<'repo> {
|
||||
entry: git2::TreeEntry<'repo>,
|
||||
}
|
||||
|
||||
impl<'repo> From<git2::TreeEntry<'repo>> for TreeEntry<'repo> {
|
||||
fn from(entry: git2::TreeEntry<'repo>) -> Self {
|
||||
TreeEntry { entry }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'repo> TreeEntry<'repo> {
|
||||
pub fn filemode(&self) -> i32 {
|
||||
self.entry.filemode()
|
||||
}
|
||||
|
||||
pub fn to_object(&self, repo: &'repo Repository) -> Result<git2::Object> {
|
||||
self.entry.to_object(repo.into()).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<git2::ObjectType> {
|
||||
self.entry.kind()
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Oid {
|
||||
self.entry.id().into()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.entry.name()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum FileMode {
|
||||
Blob,
|
||||
BlobExecutable,
|
||||
Link,
|
||||
Tree,
|
||||
}
|
||||
|
||||
impl From<FileMode> for git2::FileMode {
|
||||
fn from(filemod: FileMode) -> Self {
|
||||
match filemod {
|
||||
FileMode::Blob => git2::FileMode::Blob,
|
||||
FileMode::BlobExecutable => git2::FileMode::BlobExecutable,
|
||||
FileMode::Link => git2::FileMode::Link,
|
||||
FileMode::Tree => git2::FileMode::Tree,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeBuilder<'repo> {
|
||||
repo: &'repo git2::Repository,
|
||||
builder: git2::build::TreeUpdateBuilder,
|
||||
base: Option<&'repo git2::Tree<'repo>>,
|
||||
}
|
||||
|
||||
impl<'repo> TreeBuilder<'repo> {
|
||||
pub fn new(repo: &'repo Repository, base: Option<&'repo Tree>) -> Self {
|
||||
TreeBuilder {
|
||||
repo: repo.into(),
|
||||
builder: git2::build::TreeUpdateBuilder::new(),
|
||||
base: base.map(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upsert<P: AsRef<Path>>(&mut self, filename: P, oid: Oid, filemode: FileMode) {
|
||||
self.builder
|
||||
.upsert(filename.as_ref(), oid.into(), filemode.into());
|
||||
}
|
||||
|
||||
pub fn remove<P: AsRef<Path>>(&mut self, filename: P) {
|
||||
self.builder.remove(filename.as_ref());
|
||||
}
|
||||
|
||||
pub fn write(&mut self) -> Result<Oid> {
|
||||
let repo: &git2::Repository = self.repo;
|
||||
if let Some(base) = self.base {
|
||||
let tree_id = self.builder.create_updated(repo, base)?;
|
||||
Ok(tree_id.into())
|
||||
} else {
|
||||
let empty_tree_id = repo.treebuilder(None)?.write()?;
|
||||
let empty_tree = repo.find_tree(empty_tree_id)?;
|
||||
let tree_id = self.builder.create_updated(repo, &empty_tree)?;
|
||||
Ok(tree_id.into())
|
||||
}
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
mod convert;
|
||||
mod parse;
|
||||
mod scheme;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use bstr::ByteSlice;
|
||||
pub use convert::ConvertError;
|
||||
pub use parse::Error as ParseError;
|
||||
pub use scheme::Scheme;
|
||||
|
||||
#[derive(Default, Clone, Hash, PartialEq, Eq, Debug, thiserror::Error)]
|
||||
pub struct Url {
|
||||
/// The URL scheme.
|
||||
pub scheme: Scheme,
|
||||
/// The user to impersonate on the remote.
|
||||
user: Option<String>,
|
||||
/// The password associated with a user.
|
||||
password: Option<String>,
|
||||
/// The host to which to connect. Localhost is implied if `None`.
|
||||
pub host: Option<String>,
|
||||
/// When serializing, use the alternative forms as it was parsed as such.
|
||||
serialize_alternative_form: bool,
|
||||
/// The port to use when connecting to a host. If `None`, standard ports depending on `scheme` will be used.
|
||||
pub port: Option<u16>,
|
||||
/// The path portion of the URL, usually the location of the git repository.
|
||||
pub path: bstr::BString,
|
||||
}
|
||||
|
||||
impl Url {
|
||||
pub fn is_github(&self) -> bool {
|
||||
self.host
|
||||
.as_ref()
|
||||
.map_or(false, |host| host.contains("github.com"))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Url {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if !(self.serialize_alternative_form
|
||||
&& (self.scheme == Scheme::File || self.scheme == Scheme::Ssh))
|
||||
{
|
||||
f.write_str(self.scheme.as_str())?;
|
||||
f.write_str("://")?;
|
||||
}
|
||||
match (&self.user, &self.host) {
|
||||
(Some(user), Some(host)) => {
|
||||
f.write_str(user)?;
|
||||
if let Some(password) = &self.password {
|
||||
f.write_str(":")?;
|
||||
f.write_str(password)?;
|
||||
}
|
||||
f.write_str("@")?;
|
||||
f.write_str(host)?;
|
||||
}
|
||||
(None, Some(host)) => {
|
||||
f.write_str(host)?;
|
||||
}
|
||||
(None, None) => {}
|
||||
(Some(_user), None) => {
|
||||
unreachable!("BUG: should not be possible to have a user but no host")
|
||||
}
|
||||
};
|
||||
if let Some(port) = &self.port {
|
||||
f.write_str(&format!(":{}", port))?;
|
||||
}
|
||||
if self.serialize_alternative_form && self.scheme == Scheme::Ssh {
|
||||
f.write_str(":")?;
|
||||
}
|
||||
f.write_str(self.path.to_str().unwrap())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Url {
|
||||
pub fn as_ssh(&self) -> Result<Self, ConvertError> {
|
||||
convert::to_ssh_url(self)
|
||||
}
|
||||
|
||||
pub fn as_https(&self) -> Result<Self, ConvertError> {
|
||||
convert::to_https_url(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Url {
|
||||
type Err = parse::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse::parse(s.as_bytes().into())
|
||||
}
|
||||
}
|
@ -1,128 +0,0 @@
|
||||
use bstr::ByteSlice;
|
||||
|
||||
use super::{Scheme, Url};
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ConvertError {
|
||||
#[error("Could not convert {from} to {to}")]
|
||||
UnsupportedPair { from: Scheme, to: Scheme },
|
||||
}
|
||||
|
||||
pub(crate) fn to_https_url(url: &Url) -> Result<Url, ConvertError> {
|
||||
match url.scheme {
|
||||
Scheme::Https => Ok(url.clone()),
|
||||
Scheme::Http => Ok(Url {
|
||||
scheme: Scheme::Https,
|
||||
..url.clone()
|
||||
}),
|
||||
Scheme::Ssh => Ok(Url {
|
||||
scheme: Scheme::Https,
|
||||
user: None,
|
||||
serialize_alternative_form: true,
|
||||
path: if url.path.starts_with(&[b'/']) {
|
||||
url.path.clone()
|
||||
} else {
|
||||
format!("/{}", url.path.to_str().unwrap()).into()
|
||||
},
|
||||
..url.clone()
|
||||
}),
|
||||
_ => Err(ConvertError::UnsupportedPair {
|
||||
from: url.scheme.clone(),
|
||||
to: Scheme::Ssh,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_ssh_url(url: &Url) -> Result<Url, ConvertError> {
|
||||
match url.scheme {
|
||||
Scheme::Ssh => Ok(url.clone()),
|
||||
Scheme::Http | Scheme::Https => Ok(Url {
|
||||
scheme: Scheme::Ssh,
|
||||
user: Some("git".to_string()),
|
||||
serialize_alternative_form: true,
|
||||
path: if url.path.starts_with(&[b'/']) {
|
||||
url.path.trim_start_with(|c| c == '/').into()
|
||||
} else {
|
||||
url.path.clone()
|
||||
},
|
||||
..url.clone()
|
||||
}),
|
||||
_ => Err(ConvertError::UnsupportedPair {
|
||||
from: url.scheme.clone(),
|
||||
to: Scheme::Ssh,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_https_url_test() {
|
||||
for (input, expected) in [
|
||||
(
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"http://github.com/gitbutlerapp/gitbutler.git",
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"git@github.com:gitbutlerapp/gitbutler.git",
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"ssh://git@github.com/gitbutlerapp/gitbutler.git",
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"git@bitbucket.org:gitbutler-nikita/test.git",
|
||||
"https://bitbucket.org/gitbutler-nikita/test.git",
|
||||
),
|
||||
(
|
||||
"https://bitbucket.org/gitbutler-nikita/test.git",
|
||||
"https://bitbucket.org/gitbutler-nikita/test.git",
|
||||
),
|
||||
] {
|
||||
let url = input.parse().unwrap();
|
||||
let https_url = to_https_url(&url).unwrap();
|
||||
assert_eq!(https_url.to_string(), expected, "test case {}", url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_ssh_url_test() {
|
||||
for (input, expected) in [
|
||||
(
|
||||
"git@github.com:gitbutlerapp/gitbutler.git",
|
||||
"git@github.com:gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
"git@github.com:gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"https://github.com/gitbutlerapp/gitbutler.git",
|
||||
"git@github.com:gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"ssh://git@github.com/gitbutlerapp/gitbutler.git",
|
||||
"ssh://git@github.com/gitbutlerapp/gitbutler.git",
|
||||
),
|
||||
(
|
||||
"https://bitbucket.org/gitbutler-nikita/test.git",
|
||||
"git@bitbucket.org:gitbutler-nikita/test.git",
|
||||
),
|
||||
(
|
||||
"git@bitbucket.org:gitbutler-nikita/test.git",
|
||||
"git@bitbucket.org:gitbutler-nikita/test.git",
|
||||
),
|
||||
] {
|
||||
let url = input.parse().unwrap();
|
||||
let ssh_url = to_ssh_url(&url).unwrap();
|
||||
assert_eq!(ssh_url.to_string(), expected, "test case {}", url);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use bstr;
|
||||
use bstr::{BStr, BString, ByteSlice};
|
||||
|
||||
use super::{Scheme, Url};
|
||||
|
||||
/// The Error returned by [`parse()`]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Could not decode URL as UTF8")]
|
||||
Utf8(#[from] std::str::Utf8Error),
|
||||
#[error(transparent)]
|
||||
Url(#[from] url::ParseError),
|
||||
#[error("URLs need to specify the path to the repository")]
|
||||
MissingResourceLocation,
|
||||
#[error("file URLs require an absolute or relative path to the repository")]
|
||||
MissingRepositoryPath,
|
||||
#[error("\"{url}\" is not a valid local path")]
|
||||
NotALocalFile { url: BString },
|
||||
#[error("Relative URLs are not permitted: {url:?}")]
|
||||
RelativeUrl { url: String },
|
||||
}
|
||||
|
||||
fn str_to_protocol(s: &str) -> Scheme {
|
||||
Scheme::from(s)
|
||||
}
|
||||
|
||||
fn guess_protocol(url: &[u8]) -> Option<&str> {
|
||||
match url.find_byte(b':') {
|
||||
Some(colon_pos) => {
|
||||
if url[..colon_pos].find_byteset(b"@.").is_some() {
|
||||
"ssh"
|
||||
} else {
|
||||
url.get(colon_pos + 1..).and_then(|from_colon| {
|
||||
(from_colon.contains(&b'/') || from_colon.contains(&b'\\')).then_some("file")
|
||||
})?
|
||||
}
|
||||
}
|
||||
None => "file",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Extract the path part from an SCP-like URL `[user@]host.xz:path/to/repo.git/`
|
||||
fn extract_scp_path(url: &str) -> Option<&str> {
|
||||
url.splitn(2, ':').last()
|
||||
}
|
||||
|
||||
fn sanitize_for_protocol<'a>(protocol: &str, url: &'a str) -> Cow<'a, str> {
|
||||
match protocol {
|
||||
"ssh" => url.replacen(':', "/", 1).into(),
|
||||
_ => url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_no_explicit_protocol(url: &[u8]) -> bool {
|
||||
url.find(b"://").is_none()
|
||||
}
|
||||
|
||||
fn to_owned_url(url: &url::Url) -> Url {
|
||||
let password = url.password();
|
||||
Url {
|
||||
serialize_alternative_form: false,
|
||||
scheme: str_to_protocol(url.scheme()),
|
||||
password: password.map(ToOwned::to_owned),
|
||||
user: if url.username().is_empty() && password.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(url.username().into())
|
||||
},
|
||||
host: url.host_str().map(Into::into),
|
||||
port: url.port(),
|
||||
path: url.path().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the given `bytes` as git url.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// We cannot and should never have to deal with UTF-16 encoded windows strings, so bytes input is acceptable.
|
||||
/// For file-paths, we don't expect UTF8 encoding either.
|
||||
pub fn parse(input: &BStr) -> Result<Url, Error> {
|
||||
let guessed_protocol =
|
||||
guess_protocol(input).ok_or_else(|| Error::NotALocalFile { url: input.into() })?;
|
||||
let path_without_file_protocol = input.strip_prefix(b"file://");
|
||||
if path_without_file_protocol.is_some()
|
||||
|| (has_no_explicit_protocol(input) && guessed_protocol == "file")
|
||||
{
|
||||
let path =
|
||||
path_without_file_protocol.map_or_else(|| input.into(), |stripped_path| stripped_path);
|
||||
if path.is_empty() {
|
||||
return Err(Error::MissingRepositoryPath);
|
||||
}
|
||||
let input_starts_with_file_protocol = input.starts_with(b"file://");
|
||||
if input_starts_with_file_protocol {
|
||||
let wanted = &[b'/'];
|
||||
if !wanted.iter().any(|w| path.contains(w)) {
|
||||
return Err(Error::MissingRepositoryPath);
|
||||
}
|
||||
}
|
||||
return Ok(Url {
|
||||
scheme: Scheme::File,
|
||||
path: path.into(),
|
||||
serialize_alternative_form: !input_starts_with_file_protocol,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let url_str = std::str::from_utf8(input)?;
|
||||
let (mut url, mut scp_path) = match url::Url::parse(url_str) {
|
||||
Ok(url) => (url, None),
|
||||
Err(url::ParseError::RelativeUrlWithoutBase) => {
|
||||
// happens with bare paths as well as scp like paths. The latter contain a ':' past the host portion,
|
||||
// which we are trying to detect.
|
||||
(
|
||||
url::Url::parse(&format!(
|
||||
"{}://{}",
|
||||
guessed_protocol,
|
||||
sanitize_for_protocol(guessed_protocol, url_str)
|
||||
))?,
|
||||
extract_scp_path(url_str),
|
||||
)
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
// SCP like URLs without user parse as 'something' with the scheme being the 'host'. Hosts always have dots.
|
||||
if url.scheme().find('.').is_some() {
|
||||
// try again with prefixed protocol
|
||||
url = url::Url::parse(&format!("ssh://{}", sanitize_for_protocol("ssh", url_str)))?;
|
||||
scp_path = extract_scp_path(url_str);
|
||||
}
|
||||
if url.path().is_empty() && ["ssh", "git"].contains(&url.scheme()) {
|
||||
return Err(Error::MissingResourceLocation);
|
||||
}
|
||||
if url.cannot_be_a_base() {
|
||||
return Err(Error::RelativeUrl { url: url.into() });
|
||||
}
|
||||
|
||||
let mut url = to_owned_url(&url);
|
||||
if let Some(path) = scp_path {
|
||||
url.path = path.into();
|
||||
url.serialize_alternative_form = true;
|
||||
}
|
||||
Ok(url)
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
/// A scheme or protocol for use in a [`Url`][super::Url].
|
||||
///
|
||||
/// It defines how to talk to a given repository.
|
||||
#[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
|
||||
pub enum Scheme {
|
||||
/// A local resource that is accessible on the current host.
|
||||
File,
|
||||
/// A git daemon, like `File` over TCP/IP.
|
||||
Git,
|
||||
/// Launch `git-upload-pack` through an `ssh` tunnel.
|
||||
#[default]
|
||||
Ssh,
|
||||
/// Use the HTTP protocol to talk to git servers.
|
||||
Http,
|
||||
/// Use the HTTPS protocol to talk to git servers.
|
||||
Https,
|
||||
/// Any other protocol or transport that isn't known at compile time.
|
||||
///
|
||||
/// It's used to support plug-in transports.
|
||||
Ext(String),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for Scheme {
|
||||
fn from(value: &'a str) -> Self {
|
||||
match value {
|
||||
"ssh" => Scheme::Ssh,
|
||||
"file" => Scheme::File,
|
||||
"git" => Scheme::Git,
|
||||
"http" => Scheme::Http,
|
||||
"https" => Scheme::Https,
|
||||
unknown => Scheme::Ext(unknown.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
/// Return ourselves parseable name.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::File => "file",
|
||||
Self::Git => "git",
|
||||
Self::Ssh => "ssh",
|
||||
Self::Http => "http",
|
||||
Self::Https => "https",
|
||||
Self::Ext(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Scheme {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
@ -1 +1,82 @@
|
||||
pub mod commands;
|
||||
pub mod commands {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
const GITHUB_CLIENT_ID: &str = "cd51880daa675d9e6452";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct Verification {
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument]
|
||||
pub async fn init_device_oauth() -> Result<Verification, Error> {
|
||||
let mut req_body = HashMap::new();
|
||||
req_body.insert("client_id", GITHUB_CLIENT_ID);
|
||||
req_body.insert("scope", "repo");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post("https://github.com/login/device/code")
|
||||
.headers(headers)
|
||||
.json(&req_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
let rsp_body = res.text().await.context("Failed to get response body")?;
|
||||
|
||||
serde_json::from_str(&rsp_body)
|
||||
.context("Failed to parse response body")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument]
|
||||
pub async fn check_auth_status(device_code: &str) -> Result<String, Error> {
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
struct AccessTokenContainer {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
let mut req_body = HashMap::new();
|
||||
req_body.insert("client_id", GITHUB_CLIENT_ID);
|
||||
req_body.insert("device_code", device_code);
|
||||
req_body.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post("https://github.com/login/oauth/access_token")
|
||||
.headers(headers)
|
||||
.json(&req_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
let rsp_body = res.text().await.context("Failed to get response body")?;
|
||||
|
||||
serde_json::from_str::<AccessTokenContainer>(&rsp_body)
|
||||
.map(|rsp_body| rsp_body.access_token)
|
||||
.context("Failed to parse response body")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
const GITHUB_CLIENT_ID: &str = "cd51880daa675d9e6452";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct Verification {
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument]
|
||||
pub async fn init_device_oauth() -> Result<Verification, Error> {
|
||||
let mut req_body = HashMap::new();
|
||||
req_body.insert("client_id", GITHUB_CLIENT_ID);
|
||||
req_body.insert("scope", "repo");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post("https://github.com/login/device/code")
|
||||
.headers(headers)
|
||||
.json(&req_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
let rsp_body = res.text().await.context("Failed to get response body")?;
|
||||
|
||||
serde_json::from_str(&rsp_body)
|
||||
.context("Failed to parse response body")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument]
|
||||
pub async fn check_auth_status(device_code: &str) -> Result<String, Error> {
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
struct AccessTokenContainer {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
let mut req_body = HashMap::new();
|
||||
req_body.insert("client_id", GITHUB_CLIENT_ID);
|
||||
req_body.insert("device_code", device_code);
|
||||
req_body.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post("https://github.com/login/oauth/access_token")
|
||||
.headers(headers)
|
||||
.json(&req_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
let rsp_body = res.text().await.context("Failed to get response body")?;
|
||||
|
||||
serde_json::from_str::<AccessTokenContainer>(&rsp_body)
|
||||
.map(|rsp_body| rsp_body.access_token)
|
||||
.context("Failed to parse response body")
|
||||
.map_err(Into::into)
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
//! A generic UUID-based wrapper, via a newtype pattern
|
||||
//! with a few key integrations used throughout the library.
|
||||
|
||||
use std::{fmt, hash::Hash, marker::PhantomData, str};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A generic UUID-based newtype.
|
||||
///
|
||||
/// `Default` is implemented to generate a new UUID
|
||||
/// via [`Uuid::new_v4`].
|
||||
pub struct Id<T>(Uuid, PhantomData<T>);
|
||||
|
||||
impl<T> Hash for Id<T> {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.0.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialOrd for Id<T> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Ord for Id<T> {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.0.cmp(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Id<T> {
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
Id(Uuid::new_v4(), PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for Id<T> {
|
||||
fn default() -> Self {
|
||||
Self::generate()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> rusqlite::types::FromSql for Id<T> {
|
||||
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
|
||||
Uuid::parse_str(value.as_str()?)
|
||||
.map(Into::into)
|
||||
.map_err(|error| rusqlite::types::FromSqlError::Other(Box::new(error)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> rusqlite::ToSql for Id<T> {
|
||||
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
|
||||
Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialEq for Id<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.eq(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Eq for Id<T> {}
|
||||
|
||||
impl<T> From<Uuid> for Id<T> {
|
||||
fn from(value: Uuid) -> Self {
|
||||
Self(value, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for Id<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Uuid::deserialize(deserializer).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Serialize for Id<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Id<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Display for Id<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Id<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for Id<T> {}
|
||||
|
||||
impl<T> str::FromStr for Id<T> {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Uuid::parse_str(s).map(Into::into)
|
||||
}
|
||||
}
|
@ -1,7 +1,29 @@
|
||||
pub mod commands;
|
||||
mod controller;
|
||||
mod key;
|
||||
pub mod storage;
|
||||
pub mod commands {
|
||||
use tauri::Manager;
|
||||
use tracing::instrument;
|
||||
|
||||
pub use controller::*;
|
||||
pub use key::{PrivateKey, PublicKey, SignError};
|
||||
use crate::error::Error;
|
||||
|
||||
use gitbutler::keys::{controller, PublicKey};
|
||||
|
||||
impl From<controller::GetOrCreateError> for Error {
|
||||
fn from(value: controller::GetOrCreateError) -> Self {
|
||||
match value {
|
||||
controller::GetOrCreateError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get or create key");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_public_key(handle: tauri::AppHandle) -> Result<PublicKey, Error> {
|
||||
handle
|
||||
.state::<controller::Controller>()
|
||||
.get_or_create()
|
||||
.map(|key| key.public_key())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +0,0 @@
|
||||
use tauri::Manager;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::{controller, PublicKey};
|
||||
|
||||
impl From<controller::GetOrCreateError> for Error {
|
||||
fn from(value: controller::GetOrCreateError) -> Self {
|
||||
match value {
|
||||
controller::GetOrCreateError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get or create key");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_public_key(handle: tauri::AppHandle) -> Result<PublicKey, Error> {
|
||||
handle
|
||||
.state::<controller::Controller>()
|
||||
.get_or_create()
|
||||
.map(|key| key.public_key())
|
||||
.map_err(Into::into)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
use anyhow::Context;
|
||||
|
||||
use super::{storage::Storage, PrivateKey};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(storage: Storage) -> Self {
|
||||
Self { storage }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self {
|
||||
Self::new(Storage::from_path(path))
|
||||
}
|
||||
|
||||
pub fn get_or_create(&self) -> Result<PrivateKey, GetOrCreateError> {
|
||||
if let Some(key) = self.storage.get().context("failed to get key")? {
|
||||
Ok(key)
|
||||
} else {
|
||||
let key = PrivateKey::generate();
|
||||
self.storage.create(&key).context("failed to save key")?;
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetOrCreateError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use ssh_key::{HashAlg, LineEnding, SshSig};
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Eq)]
|
||||
pub struct PrivateKey(ssh_key::PrivateKey);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignError {
|
||||
#[error(transparent)]
|
||||
Ssh(#[from] ssh_key::Error),
|
||||
}
|
||||
|
||||
impl PrivateKey {
|
||||
pub fn generate() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
PublicKey::from(self)
|
||||
}
|
||||
|
||||
pub fn sign(&self, bytes: &[u8]) -> Result<String, SignError> {
|
||||
let sig = SshSig::sign(&self.0, "git", HashAlg::Sha512, bytes)?;
|
||||
sig.to_pem(LineEnding::default()).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrivateKey {
|
||||
fn default() -> Self {
|
||||
let ed25519_keypair = ssh_key::private::Ed25519Keypair::random(&mut OsRng);
|
||||
let ed25519_key = ssh_key::PrivateKey::from(ed25519_keypair);
|
||||
Self(ed25519_key)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PrivateKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.to_bytes().eq(&other.0.to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for PrivateKey {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PrivateKey {
|
||||
type Err = ssh_key::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let key = ssh_key::PrivateKey::from_openssh(s.as_bytes())?;
|
||||
Ok(Self(key))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PrivateKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0
|
||||
.to_openssh(ssh_key::LineEnding::default())
|
||||
.map_err(|_| fmt::Error)?
|
||||
.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PrivateKey {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PublicKey(ssh_key::PublicKey);
|
||||
|
||||
impl From<&PrivateKey> for PublicKey {
|
||||
fn from(value: &PrivateKey) -> Self {
|
||||
Self(value.0.public_key().clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PublicKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.to_bytes().eq(&other.0.to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PublicKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.to_openssh().map_err(|_| fmt::Error)?.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PublicKey {
|
||||
type Err = ssh_key::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let key = ssh_key::PublicKey::from_openssh(s)?;
|
||||
Ok(Self(key))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for PublicKey {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PublicKey {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_str(s.as_str()).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
use crate::storage;
|
||||
|
||||
use super::PrivateKey;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Storage {
|
||||
storage: storage::Storage,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("IO error: {0}")]
|
||||
Storage(#[from] storage::Error),
|
||||
#[error("SSH key error: {0}")]
|
||||
SSHKey(#[from] ssh_key::Error),
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(storage: storage::Storage) -> Storage {
|
||||
Storage { storage }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Storage {
|
||||
Storage::new(storage::Storage::new(path))
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Result<Option<PrivateKey>, Error> {
|
||||
self.storage
|
||||
.read("keys/ed25519")
|
||||
.map_err(Error::Storage)
|
||||
.and_then(|s| s.map(|s| s.parse().map_err(Error::SSHKey)).transpose())
|
||||
}
|
||||
|
||||
pub fn create(&self, key: &PrivateKey) -> Result<(), Error> {
|
||||
self.storage
|
||||
.write("keys/ed25519", &key.to_string())
|
||||
.map_err(Error::Storage)?;
|
||||
self.storage
|
||||
.write("keys/ed25519.pub", &key.public_key().to_string())
|
||||
.map_err(Error::Storage)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -15,37 +15,20 @@
|
||||
|
||||
pub mod analytics;
|
||||
pub mod app;
|
||||
pub mod askpass;
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod logs;
|
||||
pub mod menu;
|
||||
pub mod watcher;
|
||||
|
||||
pub mod assets;
|
||||
pub mod database;
|
||||
pub mod dedup;
|
||||
pub mod askpass;
|
||||
pub mod deltas;
|
||||
pub mod error;
|
||||
pub mod fs;
|
||||
pub mod gb_repository;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod id;
|
||||
pub mod keys;
|
||||
pub mod lock;
|
||||
pub mod path;
|
||||
pub mod project_repository;
|
||||
pub mod projects;
|
||||
pub mod reader;
|
||||
pub mod sentry;
|
||||
pub mod sessions;
|
||||
pub mod ssh;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
pub mod users;
|
||||
pub mod virtual_branches;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
pub mod writer;
|
||||
pub mod zip;
|
||||
|
@ -1,51 +0,0 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dir {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
pub fn new<P: AsRef<std::path::Path>>(path: P) -> Result<Self, std::io::Error> {
|
||||
Inner::new(path).map(Arc::new).map(|inner| Self { inner })
|
||||
}
|
||||
|
||||
pub fn batch<R>(
|
||||
&self,
|
||||
action: impl FnOnce(&std::path::Path) -> R,
|
||||
) -> Result<R, std::io::Error> {
|
||||
self.inner.batch(action)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
path: std::path::PathBuf,
|
||||
flock: Mutex<fslock::LockFile>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn new<P: AsRef<std::path::Path>>(path: P) -> Result<Self, std::io::Error> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(&path)?;
|
||||
} else if !path.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("{} is not a directory", path.display()),
|
||||
));
|
||||
}
|
||||
let flock = fslock::LockFile::open(&path.with_extension("lock")).map(Mutex::new)?;
|
||||
Ok(Self { path, flock })
|
||||
}
|
||||
|
||||
fn batch<R>(&self, action: impl FnOnce(&std::path::Path) -> R) -> Result<R, std::io::Error> {
|
||||
let mut flock = self.flock.lock().unwrap();
|
||||
|
||||
flock.lock()?;
|
||||
let result = action(&self.path);
|
||||
flock.unlock()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
@ -13,14 +13,17 @@
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use gitbutler::assets;
|
||||
use gitbutler::database;
|
||||
use gitbutler::git;
|
||||
use gitbutler::storage;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gitbutler::windows;
|
||||
use gitbutler_app::analytics;
|
||||
use gitbutler_app::app;
|
||||
use gitbutler_app::askpass;
|
||||
use gitbutler_app::assets;
|
||||
use gitbutler_app::commands;
|
||||
use gitbutler_app::database;
|
||||
use gitbutler_app::deltas;
|
||||
use gitbutler_app::git;
|
||||
use gitbutler_app::github;
|
||||
use gitbutler_app::keys;
|
||||
use gitbutler_app::logs;
|
||||
@ -28,12 +31,9 @@ use gitbutler_app::menu;
|
||||
use gitbutler_app::projects;
|
||||
use gitbutler_app::sentry;
|
||||
use gitbutler_app::sessions;
|
||||
use gitbutler_app::storage;
|
||||
use gitbutler_app::users;
|
||||
use gitbutler_app::virtual_branches;
|
||||
use gitbutler_app::watcher;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gitbutler_app::windows;
|
||||
use gitbutler_app::zip;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@ -101,7 +101,12 @@ fn main() {
|
||||
|
||||
tracing::info!(version = %app_handle.package_info().version, name = %app_handle.package_info().name, "starting app");
|
||||
|
||||
let askpass_broker = askpass::AskpassBroker::init(app_handle.clone());
|
||||
let askpass_broker = gitbutler::askpass::AskpassBroker::init({
|
||||
let handle = app_handle.clone();
|
||||
move |event| {
|
||||
handle.emit_all("git_prompt", event).expect("tauri event emission doesn't fail in practice")
|
||||
}
|
||||
});
|
||||
app_handle.manage(askpass_broker);
|
||||
|
||||
let storage_controller = storage::Storage::new(&app_data_dir);
|
||||
@ -110,16 +115,16 @@ fn main() {
|
||||
let watcher_controller = watcher::Watchers::new(app_handle.clone());
|
||||
app_handle.manage(watcher_controller.clone());
|
||||
|
||||
let projects_storage_controller = projects::storage::Storage::new(storage_controller.clone());
|
||||
let projects_storage_controller = gitbutler::projects::storage::Storage::new(storage_controller.clone());
|
||||
app_handle.manage(projects_storage_controller.clone());
|
||||
|
||||
let users_storage_controller = users::storage::Storage::new(storage_controller.clone());
|
||||
let users_storage_controller = gitbutler::users::storage::Storage::new(storage_controller.clone());
|
||||
app_handle.manage(users_storage_controller.clone());
|
||||
|
||||
let users_controller = users::Controller::new(users_storage_controller.clone());
|
||||
let users_controller = gitbutler::users::Controller::new(users_storage_controller.clone());
|
||||
app_handle.manage(users_controller.clone());
|
||||
|
||||
let projects_controller = projects::Controller::new(
|
||||
let projects_controller = gitbutler::projects::Controller::new(
|
||||
app_data_dir.clone(),
|
||||
projects_storage_controller.clone(),
|
||||
users_controller.clone(),
|
||||
@ -132,21 +137,21 @@ fn main() {
|
||||
let database_controller = database::Database::open_in_directory(&app_data_dir).expect("failed to open database");
|
||||
app_handle.manage(database_controller.clone());
|
||||
|
||||
let zipper = zip::Zipper::new(&app_cache_dir);
|
||||
let zipper = gitbutler::zip::Zipper::new(&app_cache_dir);
|
||||
app_handle.manage(zipper.clone());
|
||||
|
||||
app_handle.manage(zip::Controller::new(app_data_dir.clone(), app_log_dir.clone(), zipper.clone(), projects_controller.clone()));
|
||||
app_handle.manage(gitbutler::zip::Controller::new(app_data_dir.clone(), app_log_dir.clone(), zipper.clone(), projects_controller.clone()));
|
||||
|
||||
let deltas_database_controller = deltas::database::Database::new(database_controller.clone());
|
||||
let deltas_database_controller = gitbutler::deltas::database::Database::new(database_controller.clone());
|
||||
app_handle.manage(deltas_database_controller.clone());
|
||||
|
||||
let deltas_controller = deltas::Controller::new(deltas_database_controller.clone());
|
||||
let deltas_controller = gitbutler::deltas::Controller::new(deltas_database_controller.clone());
|
||||
app_handle.manage(deltas_controller);
|
||||
|
||||
let keys_storage_controller = keys::storage::Storage::new(storage_controller.clone());
|
||||
let keys_storage_controller = gitbutler::keys::storage::Storage::new(storage_controller.clone());
|
||||
app_handle.manage(keys_storage_controller.clone());
|
||||
|
||||
let keys_controller = keys::Controller::new(keys_storage_controller.clone());
|
||||
let keys_controller = gitbutler::keys::Controller::new(keys_storage_controller.clone());
|
||||
app_handle.manage(keys_controller.clone());
|
||||
|
||||
let git_credentials_controller = git::credentials::Helper::new(
|
||||
@ -156,7 +161,7 @@ fn main() {
|
||||
);
|
||||
app_handle.manage(git_credentials_controller.clone());
|
||||
|
||||
app_handle.manage(virtual_branches::controller::Controller::new(
|
||||
app_handle.manage(gitbutler::virtual_branches::controller::Controller::new(
|
||||
app_data_dir.clone(),
|
||||
projects_controller.clone(),
|
||||
users_controller.clone(),
|
||||
@ -196,10 +201,10 @@ fn main() {
|
||||
};
|
||||
}
|
||||
|
||||
let sessions_database_controller = sessions::database::Database::new(database_controller.clone());
|
||||
let sessions_database_controller = gitbutler::sessions::database::Database::new(database_controller.clone());
|
||||
app_handle.manage(sessions_database_controller.clone());
|
||||
|
||||
app_handle.manage(sessions::Controller::new(
|
||||
app_handle.manage(gitbutler::sessions::Controller::new(
|
||||
app_data_dir.clone(),
|
||||
sessions_database_controller.clone(),
|
||||
projects_controller.clone(),
|
||||
|
@ -1,48 +0,0 @@
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Normalize a path to remove any `.` and `..` components
|
||||
/// and standardize the path separator to the system's default.
|
||||
///
|
||||
/// This trait is automatically implemented for anything convertible
|
||||
/// to a `&Path` (via `AsRef<Path>`).
|
||||
pub trait Normalize {
|
||||
/// Normalize a path to remove any `.` and `..` components
|
||||
/// and standardize the path separator to the system's default.
|
||||
fn normalize(&self) -> PathBuf;
|
||||
}
|
||||
|
||||
impl<P: AsRef<Path>> Normalize for P {
|
||||
fn normalize(&self) -> PathBuf {
|
||||
// Note: Copied from Cargo's codebase:
|
||||
// https://github.com/rust-lang/cargo/blob/2e4cfc2b7d43328b207879228a2ca7d427d188bb/src/cargo/util/paths.rs#L65-L90
|
||||
// License: MIT OR Apache-2.0 (this function only)
|
||||
//
|
||||
// Small modifications made by GitButler.
|
||||
|
||||
let path = self.as_ref();
|
||||
let mut components = path.components().peekable();
|
||||
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
|
||||
components.next();
|
||||
PathBuf::from(c.as_os_str())
|
||||
} else {
|
||||
PathBuf::new()
|
||||
};
|
||||
|
||||
for component in components {
|
||||
match component {
|
||||
Component::Prefix(..) => unreachable!(),
|
||||
Component::RootDir => {
|
||||
ret.push(component.as_os_str());
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
ret.pop();
|
||||
}
|
||||
Component::Normal(c) => {
|
||||
ret.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
mod config;
|
||||
pub mod conflicts;
|
||||
mod repository;
|
||||
|
||||
pub use config::Config;
|
||||
pub use repository::{LogUntil, OpenError, RemoteError, Repository};
|
||||
|
||||
pub mod signatures;
|
@ -1,51 +0,0 @@
|
||||
use crate::git;
|
||||
|
||||
pub struct Config<'a> {
|
||||
git_repository: &'a git::Repository,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a git::Repository> for Config<'a> {
|
||||
fn from(value: &'a git::Repository) -> Self {
|
||||
Self {
|
||||
git_repository: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config<'_> {
|
||||
pub fn sign_commits(&self) -> Result<bool, git::Error> {
|
||||
let sign_commits = self
|
||||
.git_repository
|
||||
.config()?
|
||||
.get_bool("gitbutler.signCommits")
|
||||
.unwrap_or(Some(false))
|
||||
.unwrap_or(false);
|
||||
Ok(sign_commits)
|
||||
}
|
||||
|
||||
pub fn user_real_comitter(&self) -> Result<bool, git::Error> {
|
||||
let gb_comitter = self
|
||||
.git_repository
|
||||
.config()?
|
||||
.get_string("gitbutler.gitbutlerCommitter")
|
||||
.unwrap_or(Some("0".to_string()))
|
||||
.unwrap_or("0".to_string());
|
||||
Ok(gb_comitter == "0")
|
||||
}
|
||||
|
||||
pub fn user_name(&self) -> Result<Option<String>, git::Error> {
|
||||
self.git_repository.config()?.get_string("user.name")
|
||||
}
|
||||
|
||||
pub fn user_email(&self) -> Result<Option<String>, git::Error> {
|
||||
self.git_repository.config()?.get_string("user.email")
|
||||
}
|
||||
|
||||
pub fn set_local(&self, key: &str, val: &str) -> Result<(), git::Error> {
|
||||
self.git_repository.config()?.set_local(key, val)
|
||||
}
|
||||
|
||||
pub fn get_local(&self, key: &str) -> Result<Option<String>, git::Error> {
|
||||
self.git_repository.config()?.get_local(key)
|
||||
}
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
// stuff to manage merge conflict state
|
||||
// this is the dumbest possible way to do this, but it is a placeholder
|
||||
// conflicts are stored one path per line in .git/conflicts
|
||||
// merge parent is stored in .git/base_merge_parent
|
||||
// conflicts are removed as they are resolved, the conflicts file is removed when there are no more conflicts
|
||||
// the merge parent file is removed when the merge is complete
|
||||
|
||||
use std::{
|
||||
io::{BufRead, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::git;
|
||||
|
||||
use super::Repository;
|
||||
|
||||
pub fn mark<P: AsRef<Path>, A: AsRef<[P]>>(
|
||||
repository: &Repository,
|
||||
paths: A,
|
||||
parent: Option<git::Oid>,
|
||||
) -> Result<()> {
|
||||
let paths = paths.as_ref();
|
||||
if paths.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conflicts_path = repository.git_repository.path().join("conflicts");
|
||||
// write all the file paths to a file on disk
|
||||
let mut file = std::fs::File::create(conflicts_path)?;
|
||||
for path in paths {
|
||||
file.write_all(path.as_ref().as_os_str().as_encoded_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
|
||||
if let Some(parent) = parent {
|
||||
let merge_path = repository.git_repository.path().join("base_merge_parent");
|
||||
// write all the file paths to a file on disk
|
||||
let mut file = std::fs::File::create(merge_path)?;
|
||||
file.write_all(parent.to_string().as_bytes())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_parent(repository: &Repository) -> Result<Option<git::Oid>> {
|
||||
let merge_path = repository.git_repository.path().join("base_merge_parent");
|
||||
if !merge_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(merge_path)?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
if let Some(parent) = lines.next() {
|
||||
let parent = parent?;
|
||||
let parent: git::Oid = parent.parse()?;
|
||||
Ok(Some(parent))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve<P: AsRef<Path>>(repository: &Repository, path: P) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
let conflicts_path = repository.git_repository.path().join("conflicts");
|
||||
let file = std::fs::File::open(conflicts_path.clone())?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut remaining = Vec::new();
|
||||
for line in reader.lines().map_ok(PathBuf::from) {
|
||||
let line = line?;
|
||||
if line != path {
|
||||
remaining.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// remove file
|
||||
std::fs::remove_file(conflicts_path)?;
|
||||
|
||||
// re-write file if needed
|
||||
if !remaining.is_empty() {
|
||||
mark(repository, &remaining, None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn conflicting_files(repository: &Repository) -> Result<Vec<String>> {
|
||||
let conflicts_path = repository.git_repository.path().join("conflicts");
|
||||
if !conflicts_path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(conflicts_path)?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
Ok(reader.lines().map_while(Result::ok).collect())
|
||||
}
|
||||
|
||||
pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>) -> Result<bool> {
|
||||
let conflicts_path = repository.git_repository.path().join("conflicts");
|
||||
if !conflicts_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(conflicts_path)?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut files = reader.lines().map_ok(PathBuf::from);
|
||||
if let Some(pathname) = path {
|
||||
let pathname = pathname.as_ref();
|
||||
|
||||
// check if pathname is one of the lines in conflicts_path file
|
||||
for line in files {
|
||||
let line = line?;
|
||||
|
||||
if line == pathname {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(files.next().transpose().map(|x| x.is_some())?)
|
||||
}
|
||||
}
|
||||
|
||||
// is this project still in a resolving conflict state?
|
||||
// - could be that there are no more conflicts, but the state is not committed
|
||||
pub fn is_resolving(repository: &Repository) -> bool {
|
||||
repository
|
||||
.git_repository
|
||||
.path()
|
||||
.join("base_merge_parent")
|
||||
.exists()
|
||||
}
|
||||
|
||||
pub fn clear(repository: &Repository) -> Result<()> {
|
||||
let merge_path = repository.git_repository.path().join("base_merge_parent");
|
||||
std::fs::remove_file(merge_path)?;
|
||||
|
||||
for file in conflicting_files(repository)? {
|
||||
resolve(repository, &file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,697 +0,0 @@
|
||||
use std::{
|
||||
path,
|
||||
str::FromStr,
|
||||
sync::{atomic::AtomicUsize, Arc},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{
|
||||
askpass::AskpassBroker,
|
||||
git::{self, credentials::HelpError, Url},
|
||||
keys,
|
||||
projects::{self, AuthKey},
|
||||
ssh, users,
|
||||
virtual_branches::{Branch, BranchId},
|
||||
};
|
||||
|
||||
use super::conflicts;
|
||||
|
||||
pub struct Repository {
|
||||
pub git_repository: git::Repository,
|
||||
project: projects::Project,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OpenError {
|
||||
#[error("repository not found at {0}")]
|
||||
NotFound(path::PathBuf),
|
||||
#[error(transparent)]
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<OpenError> for crate::error::Error {
|
||||
fn from(value: OpenError) -> Self {
|
||||
match value {
|
||||
OpenError::NotFound(path) => crate::error::Error::UserError {
|
||||
code: crate::error::Code::Projects,
|
||||
message: format!("{} not found", path.display()),
|
||||
},
|
||||
OpenError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
crate::error::Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
pub fn open(project: &projects::Project) -> Result<Self, OpenError> {
|
||||
git::Repository::open(&project.path)
|
||||
.map_err(|error| match error {
|
||||
git::Error::NotFound(_) => OpenError::NotFound(project.path.clone()),
|
||||
other => OpenError::Other(other.into()),
|
||||
})
|
||||
.map(|git_repository| {
|
||||
// XXX(qix-): This is a temporary measure to disable GC on the project repository.
|
||||
// XXX(qix-): We do this because the internal repository we use to store the "virtual"
|
||||
// XXX(qix-): refs and information use Git's alternative-objects mechanism to refer
|
||||
// XXX(qix-): to the project repository's objects. However, the project repository
|
||||
// XXX(qix-): has no knowledge of these refs, and will GC them away (usually after
|
||||
// XXX(qix-): about 2 weeks) which will corrupt the internal repository.
|
||||
// XXX(qix-):
|
||||
// XXX(qix-): We will ultimately move away from an internal repository for a variety
|
||||
// XXX(qix-): of reasons, but for now, this is a simple, short-term solution that we
|
||||
// XXX(qix-): can clean up later on. We're aware this isn't ideal.
|
||||
if let Ok(config) = git_repository.config().as_mut(){
|
||||
let should_set = match config.get_bool("gitbutler.didSetPrune") {
|
||||
Ok(None | Some(false)) => true,
|
||||
Ok(Some(true)) => false,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"failed to get gitbutler.didSetPrune for repository at {}; cannot disable gc: {}",
|
||||
project.path.display(),
|
||||
error
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if should_set {
|
||||
if let Err(error) = config.set_str("gc.pruneExpire", "never").and_then(|()| config.set_bool("gitbutler.didSetPrune", true)) {
|
||||
tracing::warn!(
|
||||
"failed to set gc.auto to false for repository at {}; cannot disable gc: {}",
|
||||
project.path.display(),
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"failed to get config for repository at {}; cannot disable gc",
|
||||
project.path.display()
|
||||
);
|
||||
}
|
||||
|
||||
git_repository
|
||||
})
|
||||
.map(|git_repository| Self {
|
||||
git_repository,
|
||||
project: project.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_resolving(&self) -> bool {
|
||||
conflicts::is_resolving(self)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &path::Path {
|
||||
path::Path::new(&self.project.path)
|
||||
}
|
||||
|
||||
pub fn config(&self) -> super::Config {
|
||||
super::Config::from(&self.git_repository)
|
||||
}
|
||||
|
||||
pub fn git_signatures<'a>(
|
||||
&self,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<(git::Signature<'a>, git::Signature<'a>)> {
|
||||
super::signatures::signatures(self, user).context("failed to get signatures")
|
||||
}
|
||||
|
||||
pub fn project(&self) -> &projects::Project {
|
||||
&self.project
|
||||
}
|
||||
|
||||
pub fn set_project(&mut self, project: &projects::Project) {
|
||||
self.project = project.clone();
|
||||
}
|
||||
|
||||
pub fn git_index_size(&self) -> Result<usize, git::Error> {
|
||||
let head = self.git_repository.index_size()?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
pub fn get_head(&self) -> Result<git::Reference, git::Error> {
|
||||
let head = self.git_repository.head()?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
pub fn get_wd_tree(&self) -> Result<git::Tree> {
|
||||
let tree = self.git_repository.get_wd_tree()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
pub fn is_path_ignored<P: AsRef<std::path::Path>>(&self, path: P) -> Result<bool> {
|
||||
let path = path.as_ref();
|
||||
let ignored = self.git_repository.is_path_ignored(path)?;
|
||||
Ok(ignored)
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &std::path::Path {
|
||||
self.git_repository.path().parent().unwrap()
|
||||
}
|
||||
|
||||
pub fn git_remote_branches(&self) -> Result<Vec<git::RemoteRefname>> {
|
||||
self.git_repository
|
||||
.branches(Some(git2::BranchType::Remote))?
|
||||
.flatten()
|
||||
.map(|(branch, _)| branch)
|
||||
.map(|branch| {
|
||||
git::RemoteRefname::try_from(&branch)
|
||||
.context("failed to convert branch to remote name")
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}
|
||||
|
||||
pub fn git_test_push(
|
||||
&self,
|
||||
credentials: &git::credentials::Helper,
|
||||
remote_name: &str,
|
||||
branch_name: &str,
|
||||
askpass: Option<(AskpassBroker, Option<BranchId>)>,
|
||||
) -> Result<()> {
|
||||
let target_branch_refname =
|
||||
git::Refname::from_str(&format!("refs/remotes/{}/{}", remote_name, branch_name))?;
|
||||
let branch = self.git_repository.find_branch(&target_branch_refname)?;
|
||||
let commit_id = branch.peel_to_commit()?.id();
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or(std::time::Duration::from_secs(0))
|
||||
.as_millis()
|
||||
.to_string();
|
||||
let branch_name = format!("test-push-{}", now);
|
||||
|
||||
let refname = git::RemoteRefname::from_str(&format!(
|
||||
"refs/remotes/{}/{}",
|
||||
remote_name, branch_name,
|
||||
))?;
|
||||
|
||||
match self.push(
|
||||
&commit_id,
|
||||
&refname,
|
||||
false,
|
||||
credentials,
|
||||
None,
|
||||
askpass.clone(),
|
||||
) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!(e.to_string())),
|
||||
}?;
|
||||
|
||||
let empty_refspec = Some(format!(":refs/heads/{}", branch_name));
|
||||
match self.push(
|
||||
&commit_id,
|
||||
&refname,
|
||||
false,
|
||||
credentials,
|
||||
empty_refspec,
|
||||
askpass,
|
||||
) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!(e.to_string())),
|
||||
}?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_branch_reference(&self, branch: &Branch) -> Result<()> {
|
||||
let (should_write, with_force) =
|
||||
match self.git_repository.find_reference(&branch.refname().into()) {
|
||||
Ok(reference) => match reference.target() {
|
||||
Some(head_oid) => Ok((head_oid != branch.head, true)),
|
||||
None => Ok((true, true)),
|
||||
},
|
||||
Err(git::Error::NotFound(_)) => Ok((true, false)),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
.context("failed to lookup reference")?;
|
||||
|
||||
if should_write {
|
||||
self.git_repository
|
||||
.reference(
|
||||
&branch.refname().into(),
|
||||
branch.head,
|
||||
with_force,
|
||||
"new vbranch",
|
||||
)
|
||||
.context("failed to create branch reference")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_branch_reference(&self, branch: &Branch) -> Result<()> {
|
||||
match self.git_repository.find_reference(&branch.refname().into()) {
|
||||
Ok(mut reference) => {
|
||||
reference
|
||||
.delete()
|
||||
.context("failed to delete branch reference")?;
|
||||
Ok(())
|
||||
}
|
||||
Err(git::Error::NotFound(_)) => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
.context("failed to lookup reference")
|
||||
}
|
||||
|
||||
// returns a list of commit oids from the first oid to the second oid
|
||||
pub fn l(&self, from: git::Oid, to: LogUntil) -> Result<Vec<git::Oid>> {
|
||||
match to {
|
||||
LogUntil::Commit(oid) => {
|
||||
let mut revwalk = self
|
||||
.git_repository
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from.into())
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.hide(oid.into())
|
||||
.context(format!("failed to hide {}", oid))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::Take(n) => {
|
||||
let mut revwalk = self
|
||||
.git_repository
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from.into())
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.take(n)
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::When(cond) => {
|
||||
let mut revwalk = self
|
||||
.git_repository
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from.into())
|
||||
.context(format!("failed to push {}", from))?;
|
||||
let mut oids: Vec<git::Oid> = vec![];
|
||||
for oid in revwalk {
|
||||
let oid = oid.context("failed to get oid")?;
|
||||
oids.push(oid.into());
|
||||
|
||||
let commit = self
|
||||
.git_repository
|
||||
.find_commit(oid.into())
|
||||
.context("failed to find commit")?;
|
||||
|
||||
if cond(&commit).context("failed to check condition")? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(oids)
|
||||
}
|
||||
LogUntil::End => {
|
||||
let mut revwalk = self
|
||||
.git_repository
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from.into())
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
}
|
||||
.context("failed to collect oids")
|
||||
}
|
||||
|
||||
// returns a list of commits from the first oid to the second oid
|
||||
pub fn log(&self, from: git::Oid, to: LogUntil) -> Result<Vec<git::Commit>> {
|
||||
self.l(from, to)?
|
||||
.into_iter()
|
||||
.map(|oid| self.git_repository.find_commit(oid))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to collect commits")
|
||||
}
|
||||
|
||||
// returns the number of commits between the first oid to the second oid
|
||||
pub fn distance(&self, from: git::Oid, to: git::Oid) -> Result<u32> {
|
||||
let oids = self.l(from, LogUntil::Commit(to))?;
|
||||
Ok(oids.len().try_into()?)
|
||||
}
|
||||
|
||||
pub fn commit(
|
||||
&self,
|
||||
user: Option<&users::User>,
|
||||
message: &str,
|
||||
tree: &git::Tree,
|
||||
parents: &[&git::Commit],
|
||||
signing_key: Option<&keys::PrivateKey>,
|
||||
) -> Result<git::Oid> {
|
||||
let (author, committer) = self.git_signatures(user)?;
|
||||
if let Some(key) = signing_key {
|
||||
self.git_repository
|
||||
.commit_signed(&author, message, tree, parents, key)
|
||||
.context("failed to commit signed")
|
||||
} else {
|
||||
self.git_repository
|
||||
.commit(None, &author, &committer, message, tree, parents)
|
||||
.context("failed to commit")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_to_gitbutler_server(
|
||||
&self,
|
||||
user: Option<&users::User>,
|
||||
ref_specs: &[&str],
|
||||
) -> Result<bool, RemoteError> {
|
||||
let url = self
|
||||
.project
|
||||
.api
|
||||
.as_ref()
|
||||
.ok_or(RemoteError::Other(anyhow::anyhow!("api not set")))?
|
||||
.code_git_url
|
||||
.as_ref()
|
||||
.ok_or(RemoteError::Other(anyhow::anyhow!("code_git_url not set")))?
|
||||
.as_str()
|
||||
.parse::<Url>()
|
||||
.map_err(|e| RemoteError::Other(e.into()))?;
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
%url,
|
||||
"pushing code to gb repo",
|
||||
);
|
||||
|
||||
let access_token = user
|
||||
.map(|user| user.access_token.clone())
|
||||
.ok_or(RemoteError::Auth)?;
|
||||
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
if self.project.omit_certificate_check.unwrap_or(false) {
|
||||
callbacks.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
}
|
||||
let bytes_pushed = Arc::new(AtomicUsize::new(0));
|
||||
let total_objects = Arc::new(AtomicUsize::new(0));
|
||||
{
|
||||
let byte_counter = Arc::<AtomicUsize>::clone(&bytes_pushed);
|
||||
let total_counter = Arc::<AtomicUsize>::clone(&total_objects);
|
||||
callbacks.push_transfer_progress(move |_current, total, bytes| {
|
||||
byte_counter.store(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||
total_counter.store(total, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
push_options.remote_callbacks(callbacks);
|
||||
let auth_header = format!("Authorization: {}", access_token);
|
||||
let headers = &[auth_header.as_str()];
|
||||
push_options.custom_headers(headers);
|
||||
|
||||
let mut remote = self
|
||||
.git_repository
|
||||
.remote_anonymous(&url)
|
||||
.map_err(|e| RemoteError::Other(e.into()))?;
|
||||
|
||||
remote
|
||||
.push(ref_specs, Some(&mut push_options))
|
||||
.map_err(|error| match error {
|
||||
git::Error::Network(error) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "git push failed",);
|
||||
RemoteError::Network
|
||||
}
|
||||
git::Error::Auth(error) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "git push failed",);
|
||||
RemoteError::Auth
|
||||
}
|
||||
error => RemoteError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
let bytes_pushed = bytes_pushed.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total_objects_pushed = total_objects.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.project.id,
|
||||
ref_spec = ref_specs.join(" "),
|
||||
bytes = bytes_pushed,
|
||||
objects = total_objects_pushed,
|
||||
"pushed to gb repo tmp ref",
|
||||
);
|
||||
|
||||
Ok(total_objects_pushed > 0)
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&self,
|
||||
head: &git::Oid,
|
||||
branch: &git::RemoteRefname,
|
||||
with_force: bool,
|
||||
credentials: &git::credentials::Helper,
|
||||
refspec: Option<String>,
|
||||
askpass_broker: Option<(AskpassBroker, Option<BranchId>)>,
|
||||
) -> Result<(), RemoteError> {
|
||||
let refspec = refspec.unwrap_or_else(|| {
|
||||
if with_force {
|
||||
format!("+{}:refs/heads/{}", head, branch.branch())
|
||||
} else {
|
||||
format!("{}:refs/heads/{}", head, branch.branch())
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE(qix-): This is a nasty hack, however the codebase isn't structured
|
||||
// NOTE(qix-): in a way that allows us to really incorporate new backends
|
||||
// NOTE(qix-): without a lot of work. This is a temporary measure to
|
||||
// NOTE(qix-): work around a time-sensitive change that was necessary
|
||||
// NOTE(qix-): without having to refactor a large portion of the codebase.
|
||||
if self.project.preferred_key == AuthKey::SystemExecutable {
|
||||
let path = self.path().to_path_buf();
|
||||
let remote = branch.remote().to_string();
|
||||
return std::thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(gitbutler_git::push(
|
||||
path,
|
||||
gitbutler_git::tokio::TokioExecutor,
|
||||
&remote,
|
||||
gitbutler_git::RefSpec::parse(refspec).unwrap(),
|
||||
with_force,
|
||||
handle_git_prompt_push,
|
||||
askpass_broker,
|
||||
))
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.map_err(|e| RemoteError::Other(e.into()));
|
||||
}
|
||||
|
||||
let auth_flows = credentials.help(self, branch.remote())?;
|
||||
for (mut remote, callbacks) in auth_flows {
|
||||
if let Some(url) = remote.url().context("failed to get remote url")? {
|
||||
if !self.project.omit_certificate_check.unwrap_or(false) {
|
||||
ssh::check_known_host(&url).context("failed to check known host")?;
|
||||
}
|
||||
}
|
||||
let mut update_refs_error: Option<git2::Error> = None;
|
||||
for callback in callbacks {
|
||||
let mut cbs: git2::RemoteCallbacks = callback.into();
|
||||
if self.project.omit_certificate_check.unwrap_or(false) {
|
||||
cbs.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
}
|
||||
cbs.push_update_reference(|_reference: &str, status: Option<&str>| {
|
||||
if let Some(status) = status {
|
||||
update_refs_error = Some(git2::Error::from_str(status));
|
||||
return Err(git2::Error::from_str(status));
|
||||
};
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let push_result = remote.push(
|
||||
&[refspec.as_str()],
|
||||
Some(&mut git2::PushOptions::new().remote_callbacks(cbs)),
|
||||
);
|
||||
match push_result {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
project_id = %self.project.id,
|
||||
remote = %branch.remote(),
|
||||
%head,
|
||||
branch = branch.branch(),
|
||||
"pushed git branch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(git::Error::Auth(error) | git::Error::Http(error)) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "git push failed");
|
||||
continue;
|
||||
}
|
||||
Err(git::Error::Network(error)) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "git push failed");
|
||||
return Err(RemoteError::Network);
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(e) = update_refs_error.as_ref() {
|
||||
return Err(RemoteError::Other(anyhow::anyhow!(e.to_string())));
|
||||
}
|
||||
return Err(RemoteError::Other(error.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(RemoteError::Auth)
|
||||
}
|
||||
|
||||
pub fn fetch(
|
||||
&self,
|
||||
remote_name: &str,
|
||||
credentials: &git::credentials::Helper,
|
||||
askpass: Option<(AskpassBroker, String)>,
|
||||
) -> Result<(), RemoteError> {
|
||||
let refspec = format!("+refs/heads/*:refs/remotes/{}/*", remote_name);
|
||||
|
||||
// NOTE(qix-): This is a nasty hack, however the codebase isn't structured
|
||||
// NOTE(qix-): in a way that allows us to really incorporate new backends
|
||||
// NOTE(qix-): without a lot of work. This is a temporary measure to
|
||||
// NOTE(qix-): work around a time-sensitive change that was necessary
|
||||
// NOTE(qix-): without having to refactor a large portion of the codebase.
|
||||
if self.project.preferred_key == AuthKey::SystemExecutable {
|
||||
let path = self.path().to_path_buf();
|
||||
let remote = remote_name.to_string();
|
||||
return std::thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(gitbutler_git::fetch(
|
||||
path,
|
||||
gitbutler_git::tokio::TokioExecutor,
|
||||
&remote,
|
||||
gitbutler_git::RefSpec::parse(refspec).unwrap(),
|
||||
handle_git_prompt_fetch,
|
||||
askpass,
|
||||
))
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.map_err(|e| RemoteError::Other(e.into()));
|
||||
}
|
||||
|
||||
let auth_flows = credentials.help(self, remote_name)?;
|
||||
for (mut remote, callbacks) in auth_flows {
|
||||
if let Some(url) = remote.url().context("failed to get remote url")? {
|
||||
if !self.project.omit_certificate_check.unwrap_or(false) {
|
||||
ssh::check_known_host(&url).context("failed to check known host")?;
|
||||
}
|
||||
}
|
||||
for callback in callbacks {
|
||||
let mut fetch_opts = git2::FetchOptions::new();
|
||||
let mut cbs: git2::RemoteCallbacks = callback.into();
|
||||
if self.project.omit_certificate_check.unwrap_or(false) {
|
||||
cbs.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
}
|
||||
fetch_opts.remote_callbacks(cbs);
|
||||
fetch_opts.prune(git2::FetchPrune::On);
|
||||
|
||||
match remote.fetch(&[&refspec], Some(&mut fetch_opts)) {
|
||||
Ok(()) => {
|
||||
tracing::info!(project_id = %self.project.id, %refspec, "git fetched");
|
||||
return Ok(());
|
||||
}
|
||||
Err(git::Error::Auth(error) | git::Error::Http(error)) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "fetch failed");
|
||||
continue;
|
||||
}
|
||||
Err(git::Error::Network(error)) => {
|
||||
tracing::warn!(project_id = %self.project.id, ?error, "fetch failed");
|
||||
return Err(RemoteError::Network);
|
||||
}
|
||||
Err(error) => return Err(RemoteError::Other(error.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(RemoteError::Auth)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteError {
|
||||
#[error(transparent)]
|
||||
Help(#[from] HelpError),
|
||||
#[error("network failed")]
|
||||
Network,
|
||||
#[error("authentication failed")]
|
||||
Auth,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<RemoteError> for crate::error::Error {
|
||||
fn from(value: RemoteError) -> Self {
|
||||
match value {
|
||||
RemoteError::Help(error) => error.into(),
|
||||
RemoteError::Network => crate::error::Error::UserError {
|
||||
code: crate::error::Code::ProjectGitRemote,
|
||||
message: "Network erorr occured".to_string(),
|
||||
},
|
||||
RemoteError::Auth => crate::error::Error::UserError {
|
||||
code: crate::error::Code::ProjectGitAuth,
|
||||
message: "Project remote authentication error".to_string(),
|
||||
},
|
||||
RemoteError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
crate::error::Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OidFilter = dyn Fn(&git::Commit) -> Result<bool>;
|
||||
|
||||
pub enum LogUntil {
|
||||
Commit(git::Oid),
|
||||
Take(usize),
|
||||
When(Box<OidFilter>),
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct AskpassPromptPushContext {
|
||||
branch_id: Option<BranchId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct AskpassPromptFetchContext {
|
||||
action: String,
|
||||
}
|
||||
|
||||
async fn handle_git_prompt_push(
|
||||
prompt: String,
|
||||
askpass: Option<(AskpassBroker, Option<BranchId>)>,
|
||||
) -> Option<String> {
|
||||
if let Some((askpass_broker, branch_id)) = askpass {
|
||||
tracing::info!("received prompt for branch push {branch_id:?}: {prompt:?}");
|
||||
askpass_broker
|
||||
.submit_prompt(prompt, AskpassPromptPushContext { branch_id })
|
||||
.await
|
||||
} else {
|
||||
tracing::warn!("received askpass push prompt but no broker was supplied; returning None");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_git_prompt_fetch(
|
||||
prompt: String,
|
||||
askpass: Option<(AskpassBroker, String)>,
|
||||
) -> Option<String> {
|
||||
if let Some((askpass_broker, action)) = askpass {
|
||||
tracing::info!("received prompt for fetch with action {action:?}: {prompt:?}");
|
||||
askpass_broker
|
||||
.submit_prompt(prompt, AskpassPromptFetchContext { action })
|
||||
.await
|
||||
} else {
|
||||
tracing::warn!("received askpass fetch prompt but no broker was supplied; returning None");
|
||||
None
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
use crate::{git, users};
|
||||
|
||||
pub fn signatures<'a>(
|
||||
project_repository: &super::Repository,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<(git::Signature<'a>, git::Signature<'a>), git::Error> {
|
||||
let config = project_repository.config();
|
||||
|
||||
let author = match (user, config.user_name()?, config.user_email()?) {
|
||||
(_, Some(name), Some(email)) => git::Signature::now(&name, &email)?,
|
||||
(Some(user), _, _) => git::Signature::try_from(user)?,
|
||||
_ => git::Signature::now("GitButler", "gitbutler@gitbutler.com")?,
|
||||
};
|
||||
|
||||
let comitter = if config.user_real_comitter()? {
|
||||
author.clone()
|
||||
} else {
|
||||
git::Signature::now("GitButler", "gitbutler@gitbutler.com")?
|
||||
};
|
||||
|
||||
Ok((author, comitter))
|
||||
}
|
@ -1,10 +1,206 @@
|
||||
pub mod commands;
|
||||
mod controller;
|
||||
mod project;
|
||||
pub mod storage;
|
||||
pub mod commands {
|
||||
use std::path;
|
||||
|
||||
pub use controller::*;
|
||||
pub use project::{AuthKey, CodePushState, FetchResult, Project, ProjectId};
|
||||
pub use storage::UpdateRequest;
|
||||
use tauri::Manager;
|
||||
use tracing::instrument;
|
||||
|
||||
pub use project::ApiProject;
|
||||
use crate::error::{Code, Error};
|
||||
|
||||
use gitbutler::projects::{
|
||||
self,
|
||||
controller::{self, Controller},
|
||||
};
|
||||
|
||||
impl From<controller::UpdateError> for Error {
|
||||
fn from(value: controller::UpdateError) -> Self {
|
||||
match value {
|
||||
controller::UpdateError::Validation(
|
||||
controller::UpdateValidationError::KeyNotFound(path),
|
||||
) => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: format!("'{}' not found", path.display()),
|
||||
},
|
||||
controller::UpdateError::Validation(
|
||||
controller::UpdateValidationError::KeyNotFile(path),
|
||||
) => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: format!("'{}' is not a file", path.display()),
|
||||
},
|
||||
controller::UpdateError::NotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project not found".into(),
|
||||
},
|
||||
controller::UpdateError::Other(error) => {
|
||||
tracing::error!(?error, "failed to update project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn update_project(
|
||||
handle: tauri::AppHandle,
|
||||
project: projects::UpdateRequest,
|
||||
) -> Result<projects::Project, Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.update(&project)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::AddError> for Error {
|
||||
fn from(value: controller::AddError) -> Self {
|
||||
match value {
|
||||
controller::AddError::NotAGitRepository => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Must be a git directory".to_string(),
|
||||
},
|
||||
controller::AddError::AlreadyExists => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project already exists".to_string(),
|
||||
},
|
||||
controller::AddError::OpenProjectRepository(error) => error.into(),
|
||||
controller::AddError::NotADirectory => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Not a directory".to_string(),
|
||||
},
|
||||
controller::AddError::PathNotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Path not found".to_string(),
|
||||
},
|
||||
controller::AddError::SubmodulesNotSupported => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Repositories with git submodules are not supported".to_string(),
|
||||
},
|
||||
controller::AddError::User(error) => error.into(),
|
||||
controller::AddError::Other(error) => {
|
||||
tracing::error!(?error, "failed to add project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn add_project(
|
||||
handle: tauri::AppHandle,
|
||||
path: &path::Path,
|
||||
) -> Result<projects::Project, Error> {
|
||||
handle.state::<Controller>().add(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::GetError> for Error {
|
||||
fn from(value: controller::GetError) -> Self {
|
||||
match value {
|
||||
controller::GetError::NotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project not found".into(),
|
||||
},
|
||||
controller::GetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_project(
|
||||
handle: tauri::AppHandle,
|
||||
id: &str,
|
||||
) -> Result<projects::Project, Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle.state::<Controller>().get(&id).map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::ListError> for Error {
|
||||
fn from(value: controller::ListError) -> Self {
|
||||
match value {
|
||||
controller::ListError::Other(error) => {
|
||||
tracing::error!(?error, "failed to list projects");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_projects(handle: tauri::AppHandle) -> Result<Vec<projects::Project>, Error> {
|
||||
handle.state::<Controller>().list().map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::DeleteError> for Error {
|
||||
fn from(value: controller::DeleteError) -> Self {
|
||||
match value {
|
||||
controller::DeleteError::Other(error) => {
|
||||
tracing::error!(?error, "failed to delete project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn delete_project(handle: tauri::AppHandle, id: &str) -> Result<(), Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.delete(&id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn git_get_local_config(
|
||||
handle: tauri::AppHandle,
|
||||
id: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.get_local_config(&id, key)
|
||||
.map_err(|e| Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn git_set_local_config(
|
||||
handle: tauri::AppHandle,
|
||||
id: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.set_local_config(&id, key, value)
|
||||
.map_err(|e| Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,201 +0,0 @@
|
||||
use std::path;
|
||||
|
||||
use tauri::Manager;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
error::{Code, Error},
|
||||
projects,
|
||||
};
|
||||
|
||||
use super::controller::{self, Controller};
|
||||
|
||||
impl From<controller::UpdateError> for Error {
|
||||
fn from(value: controller::UpdateError) -> Self {
|
||||
match value {
|
||||
controller::UpdateError::Validation(
|
||||
controller::UpdateValidationError::KeyNotFound(path),
|
||||
) => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: format!("'{}' not found", path.display()),
|
||||
},
|
||||
controller::UpdateError::Validation(controller::UpdateValidationError::KeyNotFile(
|
||||
path,
|
||||
)) => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: format!("'{}' is not a file", path.display()),
|
||||
},
|
||||
controller::UpdateError::NotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project not found".into(),
|
||||
},
|
||||
controller::UpdateError::Other(error) => {
|
||||
tracing::error!(?error, "failed to update project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn update_project(
|
||||
handle: tauri::AppHandle,
|
||||
project: projects::UpdateRequest,
|
||||
) -> Result<projects::Project, Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.update(&project)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::AddError> for Error {
|
||||
fn from(value: controller::AddError) -> Self {
|
||||
match value {
|
||||
controller::AddError::NotAGitRepository => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Must be a git directory".to_string(),
|
||||
},
|
||||
controller::AddError::AlreadyExists => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project already exists".to_string(),
|
||||
},
|
||||
controller::AddError::OpenProjectRepository(error) => error.into(),
|
||||
controller::AddError::NotADirectory => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Not a directory".to_string(),
|
||||
},
|
||||
controller::AddError::PathNotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Path not found".to_string(),
|
||||
},
|
||||
controller::AddError::SubmodulesNotSupported => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Repositories with git submodules are not supported".to_string(),
|
||||
},
|
||||
controller::AddError::User(error) => error.into(),
|
||||
controller::AddError::Other(error) => {
|
||||
tracing::error!(?error, "failed to add project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn add_project(
|
||||
handle: tauri::AppHandle,
|
||||
path: &path::Path,
|
||||
) -> Result<projects::Project, Error> {
|
||||
handle.state::<Controller>().add(path).map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::GetError> for Error {
|
||||
fn from(value: controller::GetError) -> Self {
|
||||
match value {
|
||||
controller::GetError::NotFound => Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: "Project not found".into(),
|
||||
},
|
||||
controller::GetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_project(handle: tauri::AppHandle, id: &str) -> Result<projects::Project, Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle.state::<Controller>().get(&id).map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::ListError> for Error {
|
||||
fn from(value: controller::ListError) -> Self {
|
||||
match value {
|
||||
controller::ListError::Other(error) => {
|
||||
tracing::error!(?error, "failed to list projects");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_projects(handle: tauri::AppHandle) -> Result<Vec<projects::Project>, Error> {
|
||||
handle.state::<Controller>().list().map_err(Into::into)
|
||||
}
|
||||
|
||||
impl From<controller::DeleteError> for Error {
|
||||
fn from(value: controller::DeleteError) -> Self {
|
||||
match value {
|
||||
controller::DeleteError::Other(error) => {
|
||||
tracing::error!(?error, "failed to delete project");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn delete_project(handle: tauri::AppHandle, id: &str) -> Result<(), Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.delete(&id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn git_get_local_config(
|
||||
handle: tauri::AppHandle,
|
||||
id: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.get_local_config(&id, key)
|
||||
.map_err(|e| Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn git_set_local_config(
|
||||
handle: tauri::AppHandle,
|
||||
id: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), Error> {
|
||||
let id = id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".into(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.set_local_config(&id, key, value)
|
||||
.map_err(|e| Error::UserError {
|
||||
code: Code::Projects,
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
@ -1,340 +0,0 @@
|
||||
use super::{storage, storage::UpdateRequest, Project, ProjectId};
|
||||
use crate::{gb_repository, project_repository, users, watcher};
|
||||
use anyhow::Context;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
local_data_dir: PathBuf,
|
||||
projects_storage: storage::Storage,
|
||||
users: users::Controller,
|
||||
watchers: Option<watcher::Watchers>,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(
|
||||
local_data_dir: PathBuf,
|
||||
projects_storage: storage::Storage,
|
||||
users: users::Controller,
|
||||
watchers: Option<watcher::Watchers>,
|
||||
) -> Self {
|
||||
Self {
|
||||
local_data_dir,
|
||||
projects_storage,
|
||||
users,
|
||||
watchers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self {
|
||||
let pathbuf = path.as_ref().to_path_buf();
|
||||
Self {
|
||||
local_data_dir: pathbuf.clone(),
|
||||
projects_storage: storage::Storage::from_path(&pathbuf),
|
||||
users: users::Controller::from_path(&pathbuf),
|
||||
watchers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add<P: AsRef<Path>>(&self, path: P) -> Result<Project, AddError> {
|
||||
let path = path.as_ref();
|
||||
let all_projects = self
|
||||
.projects_storage
|
||||
.list()
|
||||
.context("failed to list projects from storage")?;
|
||||
if all_projects.iter().any(|project| project.path == path) {
|
||||
return Err(AddError::AlreadyExists);
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(AddError::PathNotFound);
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(AddError::NotADirectory);
|
||||
}
|
||||
if !path.join(".git").exists() {
|
||||
return Err(AddError::NotAGitRepository);
|
||||
};
|
||||
|
||||
if path.join(".gitmodules").exists() {
|
||||
return Err(AddError::SubmodulesNotSupported);
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// title is the base name of the file
|
||||
let title = path
|
||||
.iter()
|
||||
.last()
|
||||
.map_or_else(|| id.clone(), |p| p.to_str().unwrap().to_string());
|
||||
|
||||
let project = Project {
|
||||
id: ProjectId::generate(),
|
||||
title,
|
||||
path: path.to_path_buf(),
|
||||
api: None,
|
||||
use_diff_context: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// create all required directories to avoid racing later
|
||||
let user = self.users.get_user()?;
|
||||
let project_repository = project_repository::Repository::open(&project)?;
|
||||
gb_repository::Repository::open(&self.local_data_dir, &project_repository, user.as_ref())
|
||||
.context("failed to open repository")?;
|
||||
|
||||
self.projects_storage
|
||||
.add(&project)
|
||||
.context("failed to add project to storage")?;
|
||||
|
||||
// Create a .git/gitbutler directory for app data
|
||||
if let Err(error) = std::fs::create_dir_all(project.gb_dir()) {
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to create {:?} on project add", project.gb_dir());
|
||||
}
|
||||
|
||||
if let Some(watchers) = &self.watchers {
|
||||
watchers.watch(&project)?;
|
||||
}
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
pub async fn update(&self, project: &UpdateRequest) -> Result<Project, UpdateError> {
|
||||
if let Some(super::AuthKey::Local {
|
||||
private_key_path, ..
|
||||
}) = &project.preferred_key
|
||||
{
|
||||
use resolve_path::PathResolveExt;
|
||||
let private_key_path = private_key_path.resolve();
|
||||
|
||||
if !private_key_path.exists() {
|
||||
return Err(UpdateError::Validation(UpdateValidationError::KeyNotFound(
|
||||
private_key_path.to_path_buf(),
|
||||
)));
|
||||
}
|
||||
|
||||
if !private_key_path.is_file() {
|
||||
return Err(UpdateError::Validation(UpdateValidationError::KeyNotFile(
|
||||
private_key_path.to_path_buf(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let updated = self
|
||||
.projects_storage
|
||||
.update(project)
|
||||
.map_err(|error| match error {
|
||||
super::storage::Error::NotFound => UpdateError::NotFound,
|
||||
error => UpdateError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
if let Some(watchers) = &self.watchers {
|
||||
if let Some(api) = &project.api {
|
||||
if api.sync {
|
||||
if let Err(error) = watchers
|
||||
.post(watcher::Event::FetchGitbutlerData(project.id))
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
project_id = %project.id,
|
||||
?error,
|
||||
"failed to post fetch project event"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = watchers
|
||||
.post(watcher::Event::PushGitbutlerData(project.id))
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
project_id = %project.id,
|
||||
?error,
|
||||
"failed to post push project event"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &ProjectId) -> Result<Project, GetError> {
|
||||
let project = self.projects_storage.get(id).map_err(|error| match error {
|
||||
super::storage::Error::NotFound => GetError::NotFound,
|
||||
error => GetError::Other(error.into()),
|
||||
});
|
||||
if let Ok(project) = &project {
|
||||
if !project.gb_dir().exists() {
|
||||
if let Err(error) = std::fs::create_dir_all(project.gb_dir()) {
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to create {:?} on project get", project.gb_dir());
|
||||
}
|
||||
}
|
||||
// Clean up old virtual_branches.toml that was never used
|
||||
if project
|
||||
.path
|
||||
.join(".git")
|
||||
.join("virtual_branches.toml")
|
||||
.exists()
|
||||
{
|
||||
if let Err(error) =
|
||||
std::fs::remove_file(project.path.join(".git").join("virtual_branches.toml"))
|
||||
{
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to remove old virtual_branches.toml");
|
||||
}
|
||||
}
|
||||
}
|
||||
project
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<Project>, ListError> {
|
||||
self.projects_storage
|
||||
.list()
|
||||
.map_err(|error| ListError::Other(error.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: &ProjectId) -> Result<(), DeleteError> {
|
||||
let project = match self.projects_storage.get(id) {
|
||||
Ok(project) => Ok(project),
|
||||
Err(super::storage::Error::NotFound) => return Ok(()),
|
||||
Err(error) => Err(DeleteError::Other(error.into())),
|
||||
}?;
|
||||
|
||||
if let Some(watchers) = &self.watchers {
|
||||
if let Err(error) = watchers.stop(id).await {
|
||||
tracing::error!(
|
||||
project_id = %id,
|
||||
?error,
|
||||
"failed to stop watcher for project",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.projects_storage
|
||||
.purge(&project.id)
|
||||
.map_err(|error| DeleteError::Other(error.into()))?;
|
||||
|
||||
if let Err(error) = std::fs::remove_dir_all(
|
||||
self.local_data_dir
|
||||
.join("projects")
|
||||
.join(project.id.to_string()),
|
||||
) {
|
||||
tracing::error!(project_id = %id, ?error, "failed to remove project data",);
|
||||
}
|
||||
|
||||
if let Err(error) = std::fs::remove_file(project.path.join(".git/gitbutler.json")) {
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to remove .git/gitbutler.json data",);
|
||||
}
|
||||
|
||||
let virtual_branches_path = project.path.join(".git/virtual_branches.toml");
|
||||
if virtual_branches_path.exists() {
|
||||
if let Err(error) = std::fs::remove_file(virtual_branches_path) {
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to remove .git/virtual_branches.toml data",);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_local_config(
|
||||
&self,
|
||||
id: &ProjectId,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, ConfigError> {
|
||||
let project = self.projects_storage.get(id).map_err(|error| match error {
|
||||
super::storage::Error::NotFound => ConfigError::NotFound,
|
||||
error => ConfigError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
let repo = project_repository::Repository::open(&project)
|
||||
.map_err(|e| ConfigError::Other(e.into()))?;
|
||||
repo.config()
|
||||
.get_local(key)
|
||||
.map_err(|e| ConfigError::Other(e.into()))
|
||||
}
|
||||
|
||||
pub fn set_local_config(
|
||||
&self,
|
||||
id: &ProjectId,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), ConfigError> {
|
||||
let project = self.projects_storage.get(id).map_err(|error| match error {
|
||||
super::storage::Error::NotFound => ConfigError::NotFound,
|
||||
error => ConfigError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
let repo = project_repository::Repository::open(&project)
|
||||
.map_err(|e| ConfigError::Other(e.into()))?;
|
||||
repo.config()
|
||||
.set_local(key, value)
|
||||
.map_err(|e| ConfigError::Other(e.into()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("project not found")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeleteError {
|
||||
#[error(transparent)]
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ListError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetError {
|
||||
#[error("project not found")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UpdateError {
|
||||
#[error("project not found")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Validation(UpdateValidationError),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UpdateValidationError {
|
||||
#[error("{0} not found")]
|
||||
KeyNotFound(PathBuf),
|
||||
#[error("{0} is not a file")]
|
||||
KeyNotFile(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AddError {
|
||||
#[error("not a directory")]
|
||||
NotADirectory,
|
||||
#[error("not a git repository")]
|
||||
NotAGitRepository,
|
||||
#[error("path not found")]
|
||||
PathNotFound,
|
||||
#[error("project already exists")]
|
||||
AlreadyExists,
|
||||
#[error("submodules not supported")]
|
||||
SubmodulesNotSupported,
|
||||
#[error(transparent)]
|
||||
User(#[from] users::GetError),
|
||||
#[error(transparent)]
|
||||
OpenProjectRepository(#[from] project_repository::OpenError),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
use std::{
|
||||
path::{self, PathBuf},
|
||||
time,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{git, id::Id, types::default_true::DefaultTrue};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AuthKey {
|
||||
#[default]
|
||||
Default,
|
||||
Generated,
|
||||
SystemExecutable,
|
||||
GitCredentialsHelper,
|
||||
Local {
|
||||
private_key_path: path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ApiProject {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub repository_id: String,
|
||||
pub git_url: String,
|
||||
pub code_git_url: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FetchResult {
|
||||
Fetched {
|
||||
timestamp: time::SystemTime,
|
||||
},
|
||||
Error {
|
||||
timestamp: time::SystemTime,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FetchResult {
|
||||
pub fn timestamp(&self) -> &time::SystemTime {
|
||||
match self {
|
||||
FetchResult::Fetched { timestamp } | FetchResult::Error { timestamp, .. } => timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone)]
|
||||
pub struct CodePushState {
|
||||
pub id: git::Oid,
|
||||
pub timestamp: time::SystemTime,
|
||||
}
|
||||
|
||||
pub type ProjectId = Id<Project>;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct Project {
|
||||
pub id: ProjectId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub path: path::PathBuf,
|
||||
#[serde(default)]
|
||||
pub preferred_key: AuthKey,
|
||||
/// if ok_with_force_push is true, we'll not try to avoid force pushing
|
||||
/// for example, when updating base branch
|
||||
#[serde(default)]
|
||||
pub ok_with_force_push: DefaultTrue,
|
||||
pub api: Option<ApiProject>,
|
||||
#[serde(default)]
|
||||
pub gitbutler_data_last_fetch: Option<FetchResult>,
|
||||
#[serde(default)]
|
||||
pub gitbutler_code_push_state: Option<CodePushState>,
|
||||
#[serde(default)]
|
||||
pub project_data_last_fetch: Option<FetchResult>,
|
||||
#[serde(default)]
|
||||
pub omit_certificate_check: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub use_diff_context: Option<bool>,
|
||||
}
|
||||
|
||||
impl AsRef<Project> for Project {
|
||||
fn as_ref(&self) -> &Project {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn is_sync_enabled(&self) -> bool {
|
||||
self.api.as_ref().map(|api| api.sync).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn has_code_url(&self) -> bool {
|
||||
self.api
|
||||
.as_ref()
|
||||
.map(|api| api.code_git_url.is_some())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the `GitButler` state for this project.
|
||||
///
|
||||
/// Normally this is `.git/gitbutler` in the project's repository.
|
||||
pub fn gb_dir(&self) -> PathBuf {
|
||||
self.path.join(".git").join("gitbutler")
|
||||
}
|
||||
}
|
@ -1,162 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
projects::{project, ProjectId},
|
||||
storage,
|
||||
};
|
||||
|
||||
const PROJECTS_FILE: &str = "projects.json";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Storage {
|
||||
storage: storage::Storage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateRequest {
|
||||
pub id: ProjectId,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub api: Option<project::ApiProject>,
|
||||
pub gitbutler_data_last_fetched: Option<project::FetchResult>,
|
||||
pub preferred_key: Option<project::AuthKey>,
|
||||
pub ok_with_force_push: Option<bool>,
|
||||
pub gitbutler_code_push_state: Option<project::CodePushState>,
|
||||
pub project_data_last_fetched: Option<project::FetchResult>,
|
||||
pub omit_certificate_check: Option<bool>,
|
||||
pub use_diff_context: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Storage(#[from] storage::Error),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("project not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(storage: storage::Storage) -> Storage {
|
||||
Storage { storage }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Storage {
|
||||
Storage::new(storage::Storage::new(path))
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<project::Project>, Error> {
|
||||
match self.storage.read(PROJECTS_FILE)? {
|
||||
Some(projects) => {
|
||||
let all_projects: Vec<project::Project> = serde_json::from_str(&projects)?;
|
||||
let all_projects: Vec<project::Project> = all_projects
|
||||
.into_iter()
|
||||
.map(|mut p| {
|
||||
// backwards compatibility for description field
|
||||
if let Some(api_description) =
|
||||
p.api.as_ref().and_then(|api| api.description.as_ref())
|
||||
{
|
||||
p.description = Some(api_description.to_string());
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
Ok(all_projects)
|
||||
}
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &ProjectId) -> Result<project::Project, Error> {
|
||||
let projects = self.list()?;
|
||||
for project in &projects {
|
||||
self.update(&UpdateRequest {
|
||||
id: project.id,
|
||||
preferred_key: Some(project.preferred_key.clone()),
|
||||
..Default::default()
|
||||
})?;
|
||||
}
|
||||
match projects.into_iter().find(|p| p.id == *id) {
|
||||
Some(project) => Ok(project),
|
||||
None => Err(Error::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&self, update_request: &UpdateRequest) -> Result<project::Project, Error> {
|
||||
let mut projects = self.list()?;
|
||||
let project = projects
|
||||
.iter_mut()
|
||||
.find(|p| p.id == update_request.id)
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
if let Some(title) = &update_request.title {
|
||||
project.title = title.clone();
|
||||
}
|
||||
|
||||
if let Some(description) = &update_request.description {
|
||||
project.description = Some(description.clone());
|
||||
}
|
||||
|
||||
if let Some(api) = &update_request.api {
|
||||
project.api = Some(api.clone());
|
||||
}
|
||||
|
||||
if let Some(preferred_key) = &update_request.preferred_key {
|
||||
project.preferred_key = preferred_key.clone();
|
||||
}
|
||||
|
||||
if let Some(gitbutler_data_last_fetched) =
|
||||
update_request.gitbutler_data_last_fetched.as_ref()
|
||||
{
|
||||
project.gitbutler_data_last_fetch = Some(gitbutler_data_last_fetched.clone());
|
||||
}
|
||||
|
||||
if let Some(project_data_last_fetched) = update_request.project_data_last_fetched.as_ref() {
|
||||
project.project_data_last_fetch = Some(project_data_last_fetched.clone());
|
||||
}
|
||||
|
||||
if let Some(state) = update_request.gitbutler_code_push_state {
|
||||
project.gitbutler_code_push_state = Some(state);
|
||||
}
|
||||
|
||||
if let Some(ok_with_force_push) = update_request.ok_with_force_push {
|
||||
*project.ok_with_force_push = ok_with_force_push;
|
||||
}
|
||||
|
||||
if let Some(omit_certificate_check) = update_request.omit_certificate_check {
|
||||
project.omit_certificate_check = Some(omit_certificate_check);
|
||||
}
|
||||
|
||||
if let Some(use_diff_context) = update_request.use_diff_context {
|
||||
project.use_diff_context = Some(use_diff_context);
|
||||
}
|
||||
|
||||
self.storage
|
||||
.write(PROJECTS_FILE, &serde_json::to_string_pretty(&projects)?)?;
|
||||
|
||||
Ok(projects
|
||||
.iter()
|
||||
.find(|p| p.id == update_request.id)
|
||||
.unwrap()
|
||||
.clone())
|
||||
}
|
||||
|
||||
pub fn purge(&self, id: &ProjectId) -> Result<(), Error> {
|
||||
let mut projects = self.list()?;
|
||||
if let Some(index) = projects.iter().position(|p| p.id == *id) {
|
||||
projects.remove(index);
|
||||
self.storage
|
||||
.write(PROJECTS_FILE, &serde_json::to_string_pretty(&projects)?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add(&self, project: &project::Project) -> Result<(), Error> {
|
||||
let mut projects = self.list()?;
|
||||
projects.push(project.clone());
|
||||
let projects = serde_json::to_string_pretty(&projects)?;
|
||||
self.storage.write(PROJECTS_FILE, &projects)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,443 +0,0 @@
|
||||
use std::{
|
||||
fs, io, num,
|
||||
path::{Path, PathBuf},
|
||||
str,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{ser::SerializeStruct, Serialize};
|
||||
|
||||
use crate::{git, lock, path::Normalize};
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("file not found")]
|
||||
NotFound,
|
||||
#[error("io error: {0}")]
|
||||
Io(Arc<io::Error>),
|
||||
#[error(transparent)]
|
||||
From(FromError),
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Error::Io(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FromError> for Error {
|
||||
fn from(error: FromError) -> Self {
|
||||
Error::From(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Reader<'reader> {
|
||||
Filesystem(FilesystemReader),
|
||||
Commit(CommitReader<'reader>),
|
||||
Prefixed(PrefixedReader<'reader>),
|
||||
}
|
||||
|
||||
impl<'reader> Reader<'reader> {
|
||||
pub fn open<P: AsRef<Path>>(root: P) -> Result<Self, io::Error> {
|
||||
FilesystemReader::open(root).map(Reader::Filesystem)
|
||||
}
|
||||
|
||||
pub fn sub<P: AsRef<Path>>(&'reader self, prefix: P) -> Self {
|
||||
Reader::Prefixed(PrefixedReader::new(self, prefix))
|
||||
}
|
||||
|
||||
pub fn commit_id(&self) -> Option<git::Oid> {
|
||||
match self {
|
||||
Reader::Filesystem(_) => None,
|
||||
Reader::Commit(reader) => Some(reader.get_commit_oid()),
|
||||
Reader::Prefixed(reader) => reader.reader.commit_id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_commit(
|
||||
repository: &'reader git::Repository,
|
||||
commit: &git::Commit<'reader>,
|
||||
) -> Result<Self> {
|
||||
Ok(Reader::Commit(CommitReader::new(repository, commit)?))
|
||||
}
|
||||
|
||||
pub fn exists<P: AsRef<Path>>(&self, file_path: P) -> Result<bool, io::Error> {
|
||||
match self {
|
||||
Reader::Filesystem(reader) => reader.exists(file_path),
|
||||
Reader::Commit(reader) => Ok(reader.exists(file_path)),
|
||||
Reader::Prefixed(reader) => reader.exists(file_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Content, Error> {
|
||||
let mut contents = self.batch(&[path])?;
|
||||
contents
|
||||
.pop()
|
||||
.expect("batch should return at least one result")
|
||||
}
|
||||
|
||||
pub fn batch<P: AsRef<Path>>(
|
||||
&self,
|
||||
paths: &[P],
|
||||
) -> Result<Vec<Result<Content, Error>>, io::Error> {
|
||||
match self {
|
||||
Reader::Filesystem(reader) => reader.batch(|root| {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let path = root.join(path);
|
||||
if !path.exists() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let content = Content::read_from_file(&path)?;
|
||||
Ok(content)
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
Reader::Commit(reader) => Ok(paths
|
||||
.iter()
|
||||
.map(|path| reader.read(path.normalize()))
|
||||
.collect()),
|
||||
Reader::Prefixed(reader) => reader.batch(paths),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Result<Vec<PathBuf>> {
|
||||
match self {
|
||||
Reader::Filesystem(reader) => reader.list_files(dir_path.as_ref()),
|
||||
Reader::Commit(reader) => reader.list_files(dir_path.as_ref()),
|
||||
Reader::Prefixed(reader) => reader.list_files(dir_path.as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilesystemReader(lock::Dir);
|
||||
|
||||
impl FilesystemReader {
|
||||
fn open<P: AsRef<Path>>(root: P) -> Result<Self, io::Error> {
|
||||
lock::Dir::new(root).map(Self)
|
||||
}
|
||||
|
||||
fn exists<P: AsRef<Path>>(&self, path: P) -> Result<bool, io::Error> {
|
||||
let exists = self.0.batch(|root| root.join(path.as_ref()).exists())?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
fn batch<R>(&self, action: impl FnOnce(&Path) -> R) -> Result<R, io::Error> {
|
||||
self.0.batch(action)
|
||||
}
|
||||
|
||||
fn list_files<P: AsRef<Path>>(&self, path: P) -> Result<Vec<PathBuf>> {
|
||||
let path = path.as_ref();
|
||||
self.0
|
||||
.batch(|root| crate::fs::list_files(root.join(path).as_path(), &[Path::new(".git")]))?
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitReader<'reader> {
|
||||
repository: &'reader git::Repository,
|
||||
commit_oid: git::Oid,
|
||||
tree: git::Tree<'reader>,
|
||||
}
|
||||
|
||||
impl<'reader> CommitReader<'reader> {
|
||||
pub fn new(
|
||||
repository: &'reader git::Repository,
|
||||
commit: &git::Commit<'reader>,
|
||||
) -> Result<CommitReader<'reader>> {
|
||||
let tree = commit
|
||||
.tree()
|
||||
.with_context(|| format!("{}: tree not found", commit.id()))?;
|
||||
Ok(CommitReader {
|
||||
repository,
|
||||
tree,
|
||||
commit_oid: commit.id(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_commit_oid(&self) -> git::Oid {
|
||||
self.commit_oid
|
||||
}
|
||||
|
||||
fn read<P: AsRef<Path>>(&self, path: P) -> Result<Content, Error> {
|
||||
let path = path.as_ref();
|
||||
let entry = match self
|
||||
.tree
|
||||
.get_path(Path::new(path))
|
||||
.context(format!("{}: tree entry not found", path.display()))
|
||||
{
|
||||
Ok(entry) => entry,
|
||||
Err(_) => return Err(Error::NotFound),
|
||||
};
|
||||
let blob = match self.repository.find_blob(entry.id()) {
|
||||
Ok(blob) => blob,
|
||||
Err(_) => return Err(Error::NotFound),
|
||||
};
|
||||
Ok(Content::from(&blob))
|
||||
}
|
||||
|
||||
pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Result<Vec<PathBuf>> {
|
||||
let dir_path = dir_path.as_ref();
|
||||
let mut files = vec![];
|
||||
self.tree
|
||||
.walk(|root, entry| {
|
||||
if entry.kind() == Some(git2::ObjectType::Tree) {
|
||||
return git::TreeWalkResult::Continue;
|
||||
}
|
||||
|
||||
if entry.name().is_none() {
|
||||
return git::TreeWalkResult::Continue;
|
||||
}
|
||||
let entry_path = Path::new(root).join(entry.name().unwrap());
|
||||
|
||||
if !entry_path.starts_with(dir_path) {
|
||||
return git::TreeWalkResult::Continue;
|
||||
}
|
||||
|
||||
files.push(entry_path.strip_prefix(dir_path).unwrap().to_path_buf());
|
||||
|
||||
git::TreeWalkResult::Continue
|
||||
})
|
||||
.with_context(|| format!("{}: tree walk failed", dir_path.display()))?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn exists<P: AsRef<Path>>(&self, file_path: P) -> bool {
|
||||
self.tree.get_path(file_path.normalize()).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrefixedReader<'r> {
|
||||
reader: &'r Reader<'r>,
|
||||
prefix: PathBuf,
|
||||
}
|
||||
|
||||
impl<'r> PrefixedReader<'r> {
|
||||
fn new<P: AsRef<Path>>(reader: &'r Reader, prefix: P) -> Self {
|
||||
PrefixedReader {
|
||||
reader,
|
||||
prefix: prefix.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch<P: AsRef<Path>>(
|
||||
&self,
|
||||
paths: &[P],
|
||||
) -> Result<Vec<Result<Content, Error>>, io::Error> {
|
||||
let paths = paths
|
||||
.iter()
|
||||
.map(|path| self.prefix.join(path))
|
||||
.collect::<Vec<_>>();
|
||||
self.reader.batch(paths.as_slice())
|
||||
}
|
||||
|
||||
fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Result<Vec<PathBuf>> {
|
||||
self.reader.list_files(self.prefix.join(dir_path.as_ref()))
|
||||
}
|
||||
|
||||
fn exists<P: AsRef<Path>>(&self, file_path: P) -> Result<bool, io::Error> {
|
||||
self.reader.exists(self.prefix.join(file_path.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum FromError {
|
||||
#[error(transparent)]
|
||||
ParseInt(#[from] num::ParseIntError),
|
||||
#[error(transparent)]
|
||||
ParseBool(#[from] str::ParseBoolError),
|
||||
#[error("file is binary")]
|
||||
Binary,
|
||||
#[error("file too large")]
|
||||
Large,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Content {
|
||||
UTF8(String),
|
||||
Binary,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl Serialize for Content {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
Content::UTF8(text) => {
|
||||
let mut state = serializer.serialize_struct("Content", 2)?;
|
||||
state.serialize_field("type", "utf8")?;
|
||||
state.serialize_field("value", text)?;
|
||||
state.end()
|
||||
}
|
||||
Content::Binary => {
|
||||
let mut state = serializer.serialize_struct("Content", 1)?;
|
||||
state.serialize_field("type", "binary")?;
|
||||
state.end()
|
||||
}
|
||||
Content::Large => {
|
||||
let mut state = serializer.serialize_struct("Content", 1)?;
|
||||
state.serialize_field("type", "large")?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Content {
|
||||
const MAX_SIZE: usize = 1024 * 1024 * 10; // 10 MB
|
||||
|
||||
pub fn read_from_file<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
|
||||
let path = path.as_ref();
|
||||
let metadata = fs::metadata(path)?;
|
||||
if metadata.len() > Content::MAX_SIZE as u64 {
|
||||
return Ok(Content::Large);
|
||||
}
|
||||
let content = fs::read(path)?;
|
||||
Ok(content.as_slice().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Content {
|
||||
fn from(text: &str) -> Self {
|
||||
if text.len() > Self::MAX_SIZE {
|
||||
Content::Large
|
||||
} else {
|
||||
Content::UTF8(text.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&git::Blob<'_>> for Content {
|
||||
fn from(value: &git::Blob) -> Self {
|
||||
if value.size() > Content::MAX_SIZE {
|
||||
Content::Large
|
||||
} else {
|
||||
value.content().into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[u8]> for Content {
|
||||
fn from(bytes: &[u8]) -> Self {
|
||||
if bytes.len() > Self::MAX_SIZE {
|
||||
Content::Large
|
||||
} else {
|
||||
match String::from_utf8(bytes.to_vec()) {
|
||||
Err(_) => Content::Binary,
|
||||
Ok(text) => Content::UTF8(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for usize {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
match content {
|
||||
Content::UTF8(text) => text.parse().map_err(FromError::ParseInt),
|
||||
Content::Binary => Err(FromError::Binary),
|
||||
Content::Large => Err(FromError::Large),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for usize {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for String {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
match content {
|
||||
Content::UTF8(text) => Ok(text.clone()),
|
||||
Content::Binary => Err(FromError::Binary),
|
||||
Content::Large => Err(FromError::Large),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for String {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for i64 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for i64 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
let text: String = content.try_into()?;
|
||||
text.parse().map_err(FromError::ParseInt)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for u64 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for u64 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
let text: String = content.try_into()?;
|
||||
text.parse().map_err(FromError::ParseInt)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for u128 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for u128 {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
let text: String = content.try_into()?;
|
||||
text.parse().map_err(FromError::ParseInt)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Content> for bool {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: Content) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&content)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Content> for bool {
|
||||
type Error = FromError;
|
||||
|
||||
fn try_from(content: &Content) -> Result<Self, Self::Error> {
|
||||
let text: String = content.try_into()?;
|
||||
text.parse().map_err(FromError::ParseBool)
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ use sentry_tracing::SentryLayer;
|
||||
use tracing::Subscriber;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
use crate::users;
|
||||
use gitbutler::users;
|
||||
|
||||
static SENTRY_QUOTA: Quota = Quota::per_second(nonzero!(1_u32)); // 1 per second at most.
|
||||
static SENTRY_LIMIT: OnceCell<RateLimiter<NotKeyed, InMemoryState, QuantaClock>> = OnceCell::new();
|
||||
|
@ -1,15 +1,42 @@
|
||||
mod controller;
|
||||
mod iterator;
|
||||
mod reader;
|
||||
pub mod session;
|
||||
mod writer;
|
||||
pub mod commands {
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
pub mod commands;
|
||||
pub mod database;
|
||||
use crate::error::{Code, Error};
|
||||
|
||||
pub use controller::Controller;
|
||||
pub use database::Database;
|
||||
pub use iterator::SessionsIterator;
|
||||
pub use reader::SessionReader as Reader;
|
||||
pub use session::{Meta, Session, SessionError, SessionId};
|
||||
pub use writer::SessionWriter as Writer;
|
||||
use gitbutler::sessions::{
|
||||
Session,
|
||||
{controller::ListError, Controller},
|
||||
};
|
||||
|
||||
impl From<ListError> for Error {
|
||||
fn from(value: ListError) -> Self {
|
||||
match value {
|
||||
ListError::UsersError(error) => Error::from(error),
|
||||
ListError::ProjectsError(error) => Error::from(error),
|
||||
ListError::ProjectRepositoryError(error) => Error::from(error),
|
||||
ListError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_sessions(
|
||||
handle: AppHandle,
|
||||
project_id: &str,
|
||||
earliest_timestamp_ms: Option<u128>,
|
||||
) -> Result<Vec<Session>, Error> {
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.list(&project_id, earliest_timestamp_ms)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
@ -1,40 +1 @@
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::{Code, Error};
|
||||
|
||||
use super::{
|
||||
controller::{Controller, ListError},
|
||||
Session,
|
||||
};
|
||||
|
||||
impl From<ListError> for Error {
|
||||
fn from(value: ListError) -> Self {
|
||||
match value {
|
||||
ListError::UsersError(error) => Error::from(error),
|
||||
ListError::ProjectsError(error) => Error::from(error),
|
||||
ListError::ProjectRepositoryError(error) => Error::from(error),
|
||||
ListError::Other(error) => {
|
||||
tracing::error!(?error);
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_sessions(
|
||||
handle: AppHandle,
|
||||
project_id: &str,
|
||||
earliest_timestamp_ms: Option<u128>,
|
||||
) -> Result<Vec<Session>, Error> {
|
||||
let project_id = project_id.parse().map_err(|_| Error::UserError {
|
||||
code: Code::Validation,
|
||||
message: "Malformed project id".to_string(),
|
||||
})?;
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.list(&project_id, earliest_timestamp_ms)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
@ -1,91 +0,0 @@
|
||||
use std::path;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::{
|
||||
gb_repository, project_repository,
|
||||
projects::{self, ProjectId},
|
||||
users,
|
||||
};
|
||||
|
||||
use super::{Database, Session};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
local_data_dir: path::PathBuf,
|
||||
sessions_database: Database,
|
||||
|
||||
projects: projects::Controller,
|
||||
users: users::Controller,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ListError {
|
||||
#[error(transparent)]
|
||||
ProjectsError(#[from] projects::GetError),
|
||||
#[error(transparent)]
|
||||
ProjectRepositoryError(#[from] project_repository::OpenError),
|
||||
#[error(transparent)]
|
||||
UsersError(#[from] users::GetError),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(
|
||||
local_data_dir: path::PathBuf,
|
||||
sessions_database: Database,
|
||||
projects: projects::Controller,
|
||||
users: users::Controller,
|
||||
) -> Self {
|
||||
Self {
|
||||
local_data_dir,
|
||||
sessions_database,
|
||||
projects,
|
||||
users,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
earliest_timestamp_ms: Option<u128>,
|
||||
) -> Result<Vec<Session>, ListError> {
|
||||
let sessions = self
|
||||
.sessions_database
|
||||
.list_by_project_id(project_id, earliest_timestamp_ms)?;
|
||||
|
||||
let project = self.projects.get(project_id)?;
|
||||
let project_repository = project_repository::Repository::open(&project)?;
|
||||
let user = self.users.get_user()?;
|
||||
let gb_repository = gb_repository::Repository::open(
|
||||
&self.local_data_dir,
|
||||
&project_repository,
|
||||
user.as_ref(),
|
||||
)
|
||||
.context("failed to open gb repository")?;
|
||||
|
||||
// this is a hack to account for a case when we have a session created, but fs was never
|
||||
// touched, so the wathcer never picked up the session
|
||||
let current_session = gb_repository
|
||||
.get_current_session()
|
||||
.context("failed to get current session")?;
|
||||
let have_to_index = matches!(
|
||||
(current_session.as_ref(), sessions.first()),
|
||||
(Some(_), None)
|
||||
);
|
||||
if !have_to_index {
|
||||
return Ok(sessions);
|
||||
}
|
||||
|
||||
let sessions_iter = gb_repository.get_sessions_iterator()?;
|
||||
let mut sessions = sessions_iter.collect::<Result<Vec<_>, _>>()?;
|
||||
self.sessions_database
|
||||
.insert(project_id, &sessions.iter().collect::<Vec<_>>())?;
|
||||
if let Some(session) = current_session {
|
||||
self.sessions_database.insert(project_id, &[&session])?;
|
||||
sessions.insert(0, session);
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
}
|
@ -1,182 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{database, projects::ProjectId};
|
||||
|
||||
use super::session::{self, SessionId};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
database: database::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(database: database::Database) -> Database {
|
||||
Database { database }
|
||||
}
|
||||
|
||||
pub fn insert(&self, project_id: &ProjectId, sessions: &[&session::Session]) -> Result<()> {
|
||||
self.database.transaction(|tx| -> Result<()> {
|
||||
let mut stmt = insert_stmt(tx).context("Failed to prepare insert statement")?;
|
||||
for session in sessions {
|
||||
stmt.execute(rusqlite::named_params! {
|
||||
":id": session.id,
|
||||
":project_id": project_id,
|
||||
":hash": session.hash.map(|hash| hash.to_string()),
|
||||
":branch": session.meta.branch,
|
||||
":commit": session.meta.commit,
|
||||
":start_timestamp_ms": session.meta.start_timestamp_ms.to_string(),
|
||||
":last_timestamp_ms": session.meta.last_timestamp_ms.to_string(),
|
||||
})
|
||||
.context("Failed to execute insert statement")?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_by_project_id(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
earliest_timestamp_ms: Option<u128>,
|
||||
) -> Result<Vec<session::Session>> {
|
||||
self.database.transaction(|tx| {
|
||||
let mut stmt = list_by_project_id_stmt(tx)
|
||||
.context("Failed to prepare list_by_project_id statement")?;
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::named_params! {
|
||||
":project_id": project_id,
|
||||
})
|
||||
.context("Failed to execute list_by_project_id statement")?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.context("Failed to iterate over list_by_project_id results")?
|
||||
{
|
||||
let session = parse_row(row)?;
|
||||
|
||||
if let Some(earliest_timestamp_ms) = earliest_timestamp_ms {
|
||||
if session.meta.last_timestamp_ms < earliest_timestamp_ms {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push(session);
|
||||
}
|
||||
Ok(sessions)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_by_project_id_id(
|
||||
&self,
|
||||
project_id: &ProjectId,
|
||||
id: &SessionId,
|
||||
) -> Result<Option<session::Session>> {
|
||||
self.database.transaction(|tx| {
|
||||
let mut stmt = get_by_project_id_id_stmt(tx)
|
||||
.context("Failed to prepare get_by_project_id_id statement")?;
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::named_params! {
|
||||
":project_id": project_id,
|
||||
":id": id,
|
||||
})
|
||||
.context("Failed to execute get_by_project_id_id statement")?;
|
||||
if let Some(row) = rows
|
||||
.next()
|
||||
.context("Failed to iterate over get_by_project_id_id results")?
|
||||
{
|
||||
Ok(Some(parse_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, id: &SessionId) -> Result<Option<session::Session>> {
|
||||
self.database.transaction(|tx| {
|
||||
let mut stmt = get_by_id_stmt(tx).context("Failed to prepare get_by_id statement")?;
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::named_params! {
|
||||
":id": id,
|
||||
})
|
||||
.context("Failed to execute get_by_id statement")?;
|
||||
if let Some(row) = rows
|
||||
.next()
|
||||
.context("Failed to iterate over get_by_id results")?
|
||||
{
|
||||
Ok(Some(parse_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_row(row: &rusqlite::Row) -> Result<session::Session> {
|
||||
Ok(session::Session {
|
||||
id: row.get(0).context("Failed to get id")?,
|
||||
hash: row
|
||||
.get::<usize, Option<String>>(2)
|
||||
.context("Failed to get hash")?
|
||||
.map(|hash| hash.parse().context("Failed to parse hash"))
|
||||
.transpose()?,
|
||||
meta: session::Meta {
|
||||
branch: row.get(3).context("Failed to get branch")?,
|
||||
commit: row.get(4).context("Failed to get commit")?,
|
||||
start_timestamp_ms: row
|
||||
.get::<usize, String>(5)
|
||||
.context("Failed to get start_timestamp_ms")?
|
||||
.parse()
|
||||
.context("Failed to parse start_timestamp_ms")?,
|
||||
last_timestamp_ms: row
|
||||
.get::<usize, String>(6)
|
||||
.context("Failed to get last_timestamp_ms")?
|
||||
.parse()
|
||||
.context("Failed to parse last_timestamp_ms")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn list_by_project_id_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"SELECT `id`, `project_id`, `hash`, `branch`, `commit`, `start_timestamp_ms`, `last_timestamp_ms` FROM `sessions` WHERE `project_id` = :project_id ORDER BY `start_timestamp_ms` DESC",
|
||||
)?)
|
||||
}
|
||||
|
||||
fn get_by_project_id_id_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"SELECT `id`, `project_id`, `hash`, `branch`, `commit`, `start_timestamp_ms`, `last_timestamp_ms` FROM `sessions` WHERE `project_id` = :project_id AND `id` = :id",
|
||||
)?)
|
||||
}
|
||||
|
||||
fn get_by_id_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"SELECT `id`, `project_id`, `hash`, `branch`, `commit`, `start_timestamp_ms`, `last_timestamp_ms` FROM `sessions` WHERE `id` = :id",
|
||||
)?)
|
||||
}
|
||||
|
||||
fn insert_stmt<'conn>(
|
||||
tx: &'conn rusqlite::Transaction,
|
||||
) -> Result<rusqlite::CachedStatement<'conn>> {
|
||||
Ok(tx.prepare_cached(
|
||||
"INSERT INTO 'sessions' (
|
||||
`id`, `project_id`, `hash`, `branch`, `commit`, `start_timestamp_ms`, `last_timestamp_ms`
|
||||
) VALUES (
|
||||
:id, :project_id, :hash, :branch, :commit, :start_timestamp_ms, :last_timestamp_ms
|
||||
) ON CONFLICT(`id`) DO UPDATE SET
|
||||
`project_id` = :project_id,
|
||||
`hash` = :hash,
|
||||
`branch` = :branch,
|
||||
`commit` = :commit,
|
||||
`start_timestamp_ms` = :start_timestamp_ms,
|
||||
`last_timestamp_ms` = :last_timestamp_ms
|
||||
",
|
||||
)?)
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{git, reader};
|
||||
|
||||
use super::{Session, SessionError};
|
||||
|
||||
pub struct SessionsIterator<'iterator> {
|
||||
git_repository: &'iterator git::Repository,
|
||||
iter: git2::Revwalk<'iterator>,
|
||||
}
|
||||
|
||||
impl<'iterator> SessionsIterator<'iterator> {
|
||||
pub(crate) fn new(git_repository: &'iterator git::Repository) -> Result<Self> {
|
||||
let mut iter = git_repository
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
|
||||
iter.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
|
||||
.context("failed to set sorting")?;
|
||||
|
||||
let branches = git_repository.branches(None)?;
|
||||
for branch in branches {
|
||||
let (branch, _) = branch.context("failed to get branch")?;
|
||||
iter.push(branch.peel_to_commit()?.id().into())
|
||||
.with_context(|| format!("failed to push branch {:?}", branch.name()))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
git_repository,
|
||||
iter,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'iterator> Iterator for SessionsIterator<'iterator> {
|
||||
type Item = Result<Session>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.iter.next() {
|
||||
Some(Result::Ok(oid)) => {
|
||||
let commit = match self.git_repository.find_commit(oid.into()) {
|
||||
Result::Ok(commit) => commit,
|
||||
Err(err) => return Some(Err(err.into())),
|
||||
};
|
||||
|
||||
if commit.parent_count() == 0 {
|
||||
// skip initial commit, as it's impossible to get a list of files from it
|
||||
// it's only used to bootstrap the history
|
||||
return self.next();
|
||||
}
|
||||
|
||||
let commit_reader = match reader::Reader::from_commit(self.git_repository, &commit)
|
||||
{
|
||||
Result::Ok(commit_reader) => commit_reader,
|
||||
Err(err) => return Some(Err(err)),
|
||||
};
|
||||
let session = match Session::try_from(&commit_reader) {
|
||||
Result::Ok(session) => session,
|
||||
Err(SessionError::NoSession) => return None,
|
||||
Err(err) => return Some(Err(err.into())),
|
||||
};
|
||||
Some(Ok(session))
|
||||
}
|
||||
Some(Err(err)) => Some(Err(err.into())),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
@ -1,105 +0,0 @@
|
||||
use std::{collections::HashMap, path};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::{gb_repository, reader};
|
||||
|
||||
use super::Session;
|
||||
|
||||
pub struct SessionReader<'reader> {
|
||||
// reader for the current session. commit or wd
|
||||
reader: reader::Reader<'reader>,
|
||||
// reader for the previous session's commit
|
||||
previous_reader: reader::Reader<'reader>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum FileError {
|
||||
#[error(transparent)]
|
||||
Reader(#[from] reader::Error),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl<'reader> SessionReader<'reader> {
|
||||
pub fn reader(&self) -> &reader::Reader<'reader> {
|
||||
&self.reader
|
||||
}
|
||||
|
||||
pub fn open(repository: &'reader gb_repository::Repository, session: &Session) -> Result<Self> {
|
||||
let wd_reader = reader::Reader::open(&repository.root())?;
|
||||
|
||||
if let Ok(reader::Content::UTF8(current_session_id)) = wd_reader.read("session/meta/id") {
|
||||
if current_session_id == session.id.to_string() {
|
||||
let head_commit = repository.git_repository().head()?.peel_to_commit()?;
|
||||
return Ok(SessionReader {
|
||||
reader: wd_reader,
|
||||
previous_reader: reader::Reader::from_commit(
|
||||
repository.git_repository(),
|
||||
&head_commit,
|
||||
)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let session_hash = if let Some(hash) = &session.hash {
|
||||
hash
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"can not open reader for {} because it has no commit hash nor it is a current session",
|
||||
session.id
|
||||
));
|
||||
};
|
||||
|
||||
let commit = repository
|
||||
.git_repository()
|
||||
.find_commit(*session_hash)
|
||||
.context("failed to get commit")?;
|
||||
let commit_reader = reader::Reader::from_commit(repository.git_repository(), &commit)?;
|
||||
|
||||
Ok(SessionReader {
|
||||
reader: commit_reader,
|
||||
previous_reader: reader::Reader::from_commit(
|
||||
repository.git_repository(),
|
||||
&commit.parent(0)?,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files(
|
||||
&self,
|
||||
filter: Option<&[&path::Path]>,
|
||||
) -> Result<HashMap<path::PathBuf, reader::Content>, FileError> {
|
||||
let wd_dir = path::Path::new("wd");
|
||||
let mut paths = self.previous_reader.list_files(wd_dir)?;
|
||||
if let Some(filter) = filter {
|
||||
paths = paths
|
||||
.into_iter()
|
||||
.filter(|file_path| filter.iter().any(|path| file_path.eq(path)))
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
paths = paths.iter().map(|path| wd_dir.join(path)).collect();
|
||||
let files = self
|
||||
.previous_reader
|
||||
.batch(&paths)
|
||||
.context("failed to batch read")?;
|
||||
|
||||
let files = files.into_iter().collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(paths
|
||||
.into_iter()
|
||||
.zip(files)
|
||||
.filter_map(|(path, file)| {
|
||||
path.strip_prefix(wd_dir)
|
||||
.ok()
|
||||
.map(|path| (path.to_path_buf(), file))
|
||||
})
|
||||
.collect::<HashMap<_, _>>())
|
||||
}
|
||||
|
||||
pub fn file<P: AsRef<path::Path>>(&self, path: P) -> Result<reader::Content, reader::Error> {
|
||||
let path = path.as_ref();
|
||||
self.previous_reader
|
||||
.read(std::path::Path::new("wd").join(path))
|
||||
}
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
use std::path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{git, id::Id, reader};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Meta {
|
||||
// timestamp of when the session was created
|
||||
pub start_timestamp_ms: u128,
|
||||
// timestamp of when the session was last active
|
||||
pub last_timestamp_ms: u128,
|
||||
// session branch name
|
||||
pub branch: Option<String>,
|
||||
// session commit hash
|
||||
pub commit: Option<String>,
|
||||
}
|
||||
|
||||
pub type SessionId = Id<Session>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
// if hash is not set, the session is not saved aka current
|
||||
pub hash: Option<git::Oid>,
|
||||
pub meta: Meta,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SessionError {
|
||||
#[error("session does not exist")]
|
||||
NoSession,
|
||||
#[error("{0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl TryFrom<&reader::Reader<'_>> for Session {
|
||||
type Error = SessionError;
|
||||
|
||||
fn try_from(reader: &reader::Reader) -> Result<Self, Self::Error> {
|
||||
let results = reader
|
||||
.batch(&[
|
||||
path::Path::new("session/meta/id"),
|
||||
path::Path::new("session/meta/start"),
|
||||
path::Path::new("session/meta/last"),
|
||||
path::Path::new("session/meta/branch"),
|
||||
path::Path::new("session/meta/commit"),
|
||||
])
|
||||
.context("failed to batch read")?;
|
||||
|
||||
let id = &results[0];
|
||||
let start_timestamp_ms = &results[1];
|
||||
let last_timestamp_ms = &results[2];
|
||||
let branch = &results[3];
|
||||
let commit = &results[4];
|
||||
|
||||
let id = id.clone().map_err(|error| match error {
|
||||
reader::Error::NotFound => SessionError::NoSession,
|
||||
error => SessionError::Other(error.into()),
|
||||
})?;
|
||||
let id: String = id
|
||||
.try_into()
|
||||
.context("failed to parse session id as string")
|
||||
.map_err(SessionError::Other)?;
|
||||
let id: SessionId = id.parse().context("failed to parse session id as uuid")?;
|
||||
|
||||
let start_timestamp_ms = start_timestamp_ms.clone().map_err(|error| match error {
|
||||
reader::Error::NotFound => SessionError::NoSession,
|
||||
error => SessionError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
let start_timestamp_ms: u128 = start_timestamp_ms
|
||||
.try_into()
|
||||
.context("failed to parse session start timestamp as number")
|
||||
.map_err(SessionError::Other)?;
|
||||
|
||||
let last_timestamp_ms = last_timestamp_ms.clone().map_err(|error| match error {
|
||||
reader::Error::NotFound => SessionError::NoSession,
|
||||
error => SessionError::Other(error.into()),
|
||||
})?;
|
||||
|
||||
let last_timestamp_ms: u128 = last_timestamp_ms
|
||||
.try_into()
|
||||
.context("failed to parse session last timestamp as number")
|
||||
.map_err(SessionError::Other)?;
|
||||
|
||||
let branch = match branch.clone() {
|
||||
Ok(branch) => {
|
||||
let branch = branch
|
||||
.try_into()
|
||||
.context("failed to parse session branch as string")?;
|
||||
Ok(Some(branch))
|
||||
}
|
||||
Err(reader::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
.context("failed to parse session branch as string")?;
|
||||
|
||||
let commit = match commit.clone() {
|
||||
Ok(commit) => {
|
||||
let commit = commit
|
||||
.try_into()
|
||||
.context("failed to parse session commit as string")?;
|
||||
Ok(Some(commit))
|
||||
}
|
||||
Err(reader::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
.context("failed to parse session commit as string")?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
hash: reader.commit_id(),
|
||||
meta: Meta {
|
||||
start_timestamp_ms,
|
||||
last_timestamp_ms,
|
||||
branch,
|
||||
commit,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
use std::time;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::{gb_repository, reader, writer};
|
||||
|
||||
use super::Session;
|
||||
|
||||
pub struct SessionWriter<'writer> {
|
||||
repository: &'writer gb_repository::Repository,
|
||||
writer: writer::DirWriter,
|
||||
}
|
||||
|
||||
impl<'writer> SessionWriter<'writer> {
|
||||
pub fn new(repository: &'writer gb_repository::Repository) -> Result<Self, std::io::Error> {
|
||||
writer::DirWriter::open(repository.root())
|
||||
.map(|writer| SessionWriter { repository, writer })
|
||||
}
|
||||
|
||||
pub fn remove(&self) -> Result<()> {
|
||||
self.writer.remove("session")?;
|
||||
|
||||
tracing::debug!(
|
||||
project_id = %self.repository.get_project_id(),
|
||||
"deleted session"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&self, session: &Session) -> Result<()> {
|
||||
if session.hash.is_some() {
|
||||
return Err(anyhow!("can not open writer for a session with a hash"));
|
||||
}
|
||||
|
||||
let reader = reader::Reader::open(&self.repository.root())
|
||||
.context("failed to open current session reader")?;
|
||||
|
||||
let current_session_id =
|
||||
if let Ok(reader::Content::UTF8(current_session_id)) = reader.read("session/meta/id") {
|
||||
Some(current_session_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if current_session_id.is_some()
|
||||
&& current_session_id.as_ref() != Some(&session.id.to_string())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"{}: can not open writer for {} because a writer for {} is still open",
|
||||
self.repository.get_project_id(),
|
||||
session.id,
|
||||
current_session_id.unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
let mut batch = vec![writer::BatchTask::Write(
|
||||
"session/meta/last",
|
||||
time::SystemTime::now()
|
||||
.duration_since(time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
.to_string(),
|
||||
)];
|
||||
|
||||
if current_session_id.is_some()
|
||||
&& current_session_id.as_ref() == Some(&session.id.to_string())
|
||||
{
|
||||
self.writer
|
||||
.batch(&batch)
|
||||
.context("failed to write last timestamp")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
batch.push(writer::BatchTask::Write(
|
||||
"session/meta/id",
|
||||
session.id.to_string(),
|
||||
));
|
||||
batch.push(writer::BatchTask::Write(
|
||||
"session/meta/start",
|
||||
session.meta.start_timestamp_ms.to_string(),
|
||||
));
|
||||
|
||||
if let Some(branch) = session.meta.branch.as_ref() {
|
||||
batch.push(writer::BatchTask::Write(
|
||||
"session/meta/branch",
|
||||
branch.to_string(),
|
||||
));
|
||||
} else {
|
||||
batch.push(writer::BatchTask::Remove("session/meta/branch"));
|
||||
}
|
||||
|
||||
if let Some(commit) = session.meta.commit.as_ref() {
|
||||
batch.push(writer::BatchTask::Write(
|
||||
"session/meta/commit",
|
||||
commit.to_string(),
|
||||
));
|
||||
} else {
|
||||
batch.push(writer::BatchTask::Remove("session/meta/commit"));
|
||||
}
|
||||
|
||||
self.writer
|
||||
.batch(&batch)
|
||||
.context("failed to write session meta")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
use std::{env, fs, path::Path};
|
||||
|
||||
use ssh2::{self, CheckResult, KnownHostFileKind};
|
||||
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Ssh(ssh2::Error),
|
||||
#[error(transparent)]
|
||||
Io(std::io::Error),
|
||||
#[error("mismatched host key")]
|
||||
MismatchedHostKey,
|
||||
#[error("failed to check the known hosts")]
|
||||
Failure,
|
||||
}
|
||||
|
||||
pub fn check_known_host(remote_url: &git::Url) -> Result<(), Error> {
|
||||
if remote_url.scheme != git::Scheme::Ssh {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let host = if let Some(host) = remote_url.host.as_ref() {
|
||||
host
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut session = ssh2::Session::new().map_err(Error::Ssh)?;
|
||||
session
|
||||
.set_tcp_stream(std::net::TcpStream::connect(format!("{}:22", host)).map_err(Error::Io)?);
|
||||
session.handshake().map_err(Error::Ssh)?;
|
||||
|
||||
let mut known_hosts = session.known_hosts().map_err(Error::Ssh)?;
|
||||
|
||||
// Initialize the known hosts with a global known hosts file
|
||||
let dotssh = Path::new(&env::var("HOME").unwrap()).join(".ssh");
|
||||
let file = dotssh.join("known_hosts");
|
||||
if !file.exists() {
|
||||
fs::create_dir_all(&dotssh).map_err(Error::Io)?;
|
||||
fs::File::create(&file).map_err(Error::Io)?;
|
||||
}
|
||||
|
||||
known_hosts
|
||||
.read_file(&file, KnownHostFileKind::OpenSSH)
|
||||
.map_err(Error::Ssh)?;
|
||||
|
||||
// Now check to see if the seesion's host key is anywhere in the known
|
||||
// hosts file
|
||||
let (key, key_type) = session.host_key().unwrap();
|
||||
match known_hosts.check(host, key) {
|
||||
CheckResult::Match => Ok(()),
|
||||
CheckResult::Mismatch => Err(Error::MismatchedHostKey),
|
||||
CheckResult::Failure => Err(Error::Failure),
|
||||
CheckResult::NotFound => {
|
||||
tracing::info!("adding host key for {}", host);
|
||||
known_hosts
|
||||
.add(host, key, "added by gitbutler client", key_type.into())
|
||||
.map_err(Error::Ssh)?;
|
||||
known_hosts
|
||||
.write_file(&file, KnownHostFileKind::OpenSSH)
|
||||
.map_err(Error::Ssh)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::prelude::*;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Storage {
|
||||
local_data_dir: Arc<RwLock<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new<P: AsRef<Path>>(local_data_dir: P) -> Storage {
|
||||
Storage {
|
||||
local_data_dir: Arc::new(RwLock::new(local_data_dir.as_ref().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Option<String>, Error> {
|
||||
let local_data_dir = self.local_data_dir.read().unwrap();
|
||||
let file_path = local_data_dir.join(path);
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = fs::read_to_string(&file_path).map_err(Error::IO)?;
|
||||
Ok(Some(contents))
|
||||
}
|
||||
|
||||
pub fn write<P: AsRef<Path>>(&self, path: P, content: &str) -> Result<(), Error> {
|
||||
let local_data_dir = self.local_data_dir.write().unwrap();
|
||||
let file_path = local_data_dir.join(path);
|
||||
let dir = file_path.parent().unwrap();
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(dir).map_err(Error::IO)?;
|
||||
}
|
||||
fs::write(&file_path, content).map_err(Error::IO)?;
|
||||
|
||||
// Set the permissions to be user-only. We can't actually
|
||||
// do this on Windows, so we ignore that platform.
|
||||
#[cfg(target_family = "unix")]
|
||||
{
|
||||
let metadata = fs::metadata(file_path.clone())?;
|
||||
let mut permissions = metadata.permissions();
|
||||
permissions.set_mode(0o600); // User read/write
|
||||
fs::set_permissions(file_path.clone(), permissions)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
|
||||
let local_data_dir = self.local_data_dir.write().unwrap();
|
||||
let file_path = local_data_dir.join(path);
|
||||
if !file_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if file_path.is_dir() {
|
||||
fs::remove_dir_all(file_path.clone()).map_err(Error::IO)?;
|
||||
} else {
|
||||
fs::remove_file(file_path.clone()).map_err(Error::IO)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
pub mod default_true;
|
@ -1,90 +0,0 @@
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DefaultTrue(bool);
|
||||
|
||||
impl core::fmt::Debug for DefaultTrue {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
<bool as core::fmt::Debug>::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for DefaultTrue {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
<bool as core::fmt::Display>::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DefaultTrue {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
DefaultTrue(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DefaultTrue> for bool {
|
||||
#[inline]
|
||||
fn from(default_true: DefaultTrue) -> Self {
|
||||
default_true.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for DefaultTrue {
|
||||
#[inline]
|
||||
fn from(boolean: bool) -> Self {
|
||||
DefaultTrue(boolean)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for DefaultTrue {
|
||||
#[inline]
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_bool(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for DefaultTrue {
|
||||
#[inline]
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Ok(DefaultTrue(bool::deserialize(deserializer)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Deref for DefaultTrue {
|
||||
type Target = bool;
|
||||
|
||||
#[inline]
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::DerefMut for DefaultTrue {
|
||||
#[inline]
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<bool> for DefaultTrue {
|
||||
#[inline]
|
||||
fn eq(&self, other: &bool) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<DefaultTrue> for bool {
|
||||
#[inline]
|
||||
fn eq(&self, other: &DefaultTrue) -> bool {
|
||||
*self == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Not for DefaultTrue {
|
||||
type Output = bool;
|
||||
|
||||
#[inline]
|
||||
fn not(self) -> Self::Output {
|
||||
!self.0
|
||||
}
|
||||
}
|
@ -1,7 +1,82 @@
|
||||
pub mod commands;
|
||||
pub mod controller;
|
||||
pub mod storage;
|
||||
mod user;
|
||||
pub mod commands {
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
pub use controller::*;
|
||||
pub use user::User;
|
||||
use crate::{error::Error, sentry};
|
||||
|
||||
use gitbutler::{
|
||||
assets,
|
||||
users::controller::{self, Controller, GetError},
|
||||
users::User,
|
||||
};
|
||||
|
||||
impl From<GetError> for Error {
|
||||
fn from(value: GetError) -> Self {
|
||||
match value {
|
||||
GetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_user(handle: AppHandle) -> Result<Option<User>, Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
|
||||
match app.get_user()? {
|
||||
Some(user) => Ok(Some(proxy.proxy_user(user).await)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<controller::SetError> for Error {
|
||||
fn from(value: controller::SetError) -> Self {
|
||||
match value {
|
||||
controller::SetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to set user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn set_user(handle: AppHandle, user: User) -> Result<User, Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
|
||||
app.set_user(&user)?;
|
||||
|
||||
sentry::configure_scope(Some(&user));
|
||||
|
||||
Ok(proxy.proxy_user(user).await)
|
||||
}
|
||||
|
||||
impl From<controller::DeleteError> for Error {
|
||||
fn from(value: controller::DeleteError) -> Self {
|
||||
match value {
|
||||
controller::DeleteError::Other(error) => {
|
||||
tracing::error!(?error, "failed to delete user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn delete_user(handle: AppHandle) -> Result<(), Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
|
||||
app.delete_user()?;
|
||||
|
||||
sentry::configure_scope(None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,79 +0,0 @@
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{assets, error::Error, sentry};
|
||||
|
||||
use super::{
|
||||
controller::{self, Controller, GetError},
|
||||
User,
|
||||
};
|
||||
|
||||
impl From<GetError> for Error {
|
||||
fn from(value: GetError) -> Self {
|
||||
match value {
|
||||
GetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to get user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_user(handle: AppHandle) -> Result<Option<User>, Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
|
||||
match app.get_user()? {
|
||||
Some(user) => Ok(Some(proxy.proxy_user(user).await)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<controller::SetError> for Error {
|
||||
fn from(value: controller::SetError) -> Self {
|
||||
match value {
|
||||
controller::SetError::Other(error) => {
|
||||
tracing::error!(?error, "failed to set user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn set_user(handle: AppHandle, user: User) -> Result<User, Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
|
||||
app.set_user(&user)?;
|
||||
|
||||
sentry::configure_scope(Some(&user));
|
||||
|
||||
Ok(proxy.proxy_user(user).await)
|
||||
}
|
||||
|
||||
impl From<controller::DeleteError> for Error {
|
||||
fn from(value: controller::DeleteError) -> Self {
|
||||
match value {
|
||||
controller::DeleteError::Other(error) => {
|
||||
tracing::error!(?error, "failed to delete user");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn delete_user(handle: AppHandle) -> Result<(), Error> {
|
||||
let app = handle.state::<Controller>();
|
||||
|
||||
app.delete_user()?;
|
||||
|
||||
sentry::configure_scope(None);
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
use anyhow::Context;
|
||||
|
||||
use super::{storage::Storage, User};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(storage: Storage) -> Controller {
|
||||
Controller { storage }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Controller {
|
||||
Controller::new(Storage::from_path(path))
|
||||
}
|
||||
|
||||
pub fn get_user(&self) -> Result<Option<User>, GetError> {
|
||||
self.storage
|
||||
.get()
|
||||
.context("failed to get user")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_user(&self, user: &User) -> Result<(), SetError> {
|
||||
self.storage
|
||||
.set(user)
|
||||
.context("failed to set user")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete_user(&self) -> Result<(), DeleteError> {
|
||||
self.storage
|
||||
.delete()
|
||||
.context("failed to delete user")
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SetError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeleteError {
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{storage, users::user};
|
||||
|
||||
const USER_FILE: &str = "user.json";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Storage {
|
||||
storage: storage::Storage,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Storage(#[from] storage::Error),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(storage: storage::Storage) -> Storage {
|
||||
Storage { storage }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Storage {
|
||||
Storage::new(storage::Storage::new(path))
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Result<Option<user::User>, Error> {
|
||||
match self.storage.read(USER_FILE)? {
|
||||
Some(data) => Ok(Some(serde_json::from_str(&data)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, user: &user::User) -> Result<(), Error> {
|
||||
let data = serde_json::to_string(user)?;
|
||||
self.storage.write(USER_FILE, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<(), Error> {
|
||||
self.storage.delete(USER_FILE)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub name: Option<String>,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub email: String,
|
||||
pub picture: String,
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub access_token: String,
|
||||
pub role: Option<String>,
|
||||
pub github_access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub github_username: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<User> for git::Signature<'_> {
|
||||
type Error = git::Error;
|
||||
|
||||
fn try_from(value: User) -> Result<Self, Self::Error> {
|
||||
if let Some(name) = value.name {
|
||||
git::Signature::now(&name, &value.email)
|
||||
} else if let Some(name) = value.given_name {
|
||||
git::Signature::now(&name, &value.email)
|
||||
} else {
|
||||
git::Signature::now(&value.email, &value.email)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,31 +1,540 @@
|
||||
pub mod branch;
|
||||
pub use branch::{Branch, BranchId};
|
||||
pub mod context;
|
||||
pub mod target;
|
||||
pub mod commands {
|
||||
use anyhow::Context;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
pub mod errors;
|
||||
use gitbutler::error::{Code, Error};
|
||||
|
||||
mod files;
|
||||
pub use files::*;
|
||||
use crate::watcher;
|
||||
use gitbutler::askpass::AskpassBroker;
|
||||
use gitbutler::virtual_branches::{RemoteBranch, RemoteBranchData};
|
||||
use gitbutler::{
|
||||
assets, git, projects,
|
||||
projects::ProjectId,
|
||||
virtual_branches::branch::{self, BranchId, BranchOwnershipClaims},
|
||||
virtual_branches::controller::{Controller, ControllerError},
|
||||
virtual_branches::BaseBranch,
|
||||
virtual_branches::{RemoteBranchFile, VirtualBranches},
|
||||
};
|
||||
|
||||
pub mod integration;
|
||||
pub use integration::GITBUTLER_INTEGRATION_REFERENCE;
|
||||
fn into_error<E: Into<Error>>(value: ControllerError<E>) -> Error {
|
||||
match value {
|
||||
ControllerError::User(error) => error,
|
||||
ControllerError::Action(error) => error.into(),
|
||||
ControllerError::VerifyError(error) => error.into(),
|
||||
ControllerError::Other(error) => {
|
||||
tracing::error!(?error, "failed to verify branch");
|
||||
Error::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod base;
|
||||
pub use base::*;
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn commit_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: BranchId,
|
||||
message: &str,
|
||||
ownership: Option<BranchOwnershipClaims>,
|
||||
run_hooks: bool,
|
||||
) -> Result<git::Oid, Error> {
|
||||
let oid = handle
|
||||
.state::<Controller>()
|
||||
.create_commit(&project_id, &branch, message, ownership.as_ref(), run_hooks)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
pub mod controller;
|
||||
pub use controller::Controller;
|
||||
/// This is a test command. It retrieves the virtual branches state from the gitbutler repository (legacy state) and persists it into a flat TOML file
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn save_vbranches_state(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_ids: Vec<BranchId>,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.save_vbranches_state(&project_id, branch_ids)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub mod commands;
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_virtual_branches(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
) -> Result<VirtualBranches, Error> {
|
||||
let (branches, uses_diff_context, skipped_files) = handle
|
||||
.state::<Controller>()
|
||||
.list_virtual_branches(&project_id)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
|
||||
mod iterator;
|
||||
pub use iterator::BranchIterator as Iterator;
|
||||
// Migration: If use_diff_context is not already set and if there are no vbranches, set use_diff_context to true
|
||||
let has_active_branches = branches.iter().any(|branch| branch.active);
|
||||
if !uses_diff_context && !has_active_branches {
|
||||
let _ = handle
|
||||
.state::<projects::Controller>()
|
||||
.update(&projects::UpdateRequest {
|
||||
id: project_id,
|
||||
use_diff_context: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
mod r#virtual;
|
||||
pub use r#virtual::*;
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
let branches = proxy.proxy_virtual_branches(branches).await;
|
||||
Ok(VirtualBranches {
|
||||
branches,
|
||||
skipped_files,
|
||||
})
|
||||
}
|
||||
|
||||
mod remote;
|
||||
pub use remote::*;
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn create_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: branch::BranchCreateRequest,
|
||||
) -> Result<BranchId, Error> {
|
||||
let branch_id = handle
|
||||
.state::<Controller>()
|
||||
.create_virtual_branch(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(branch_id)
|
||||
}
|
||||
|
||||
mod state;
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn create_virtual_branch_from_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: git::Refname,
|
||||
) -> Result<BranchId, Error> {
|
||||
let branch_id = handle
|
||||
.state::<Controller>()
|
||||
.create_virtual_branch_from_branch(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(branch_id)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn merge_virtual_branch_upstream(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: BranchId,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.merge_virtual_branch_upstream(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_base_branch_data(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
) -> Result<Option<BaseBranch>, Error> {
|
||||
if let Some(base_branch) = handle
|
||||
.state::<Controller>()
|
||||
.get_base_branch_data(&project_id)
|
||||
.await
|
||||
.map_err(into_error)?
|
||||
{
|
||||
let proxy = handle.state::<assets::Proxy>();
|
||||
let base_branch = proxy.proxy_base_branch(base_branch).await;
|
||||
Ok(Some(base_branch))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn set_base_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: &str,
|
||||
) -> Result<BaseBranch, Error> {
|
||||
let branch_name = format!("refs/remotes/{}", branch)
|
||||
.parse()
|
||||
.context("Invalid branch name")?;
|
||||
let base_branch = handle
|
||||
.state::<Controller>()
|
||||
.set_base_branch(&project_id, &branch_name)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
let base_branch = handle
|
||||
.state::<assets::Proxy>()
|
||||
.proxy_base_branch(base_branch)
|
||||
.await;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(base_branch)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn update_base_branch(handle: AppHandle, project_id: ProjectId) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.update_base_branch(&project_id)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn update_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: branch::BranchUpdateRequest,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.update_virtual_branch(&project_id, branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn delete_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.delete_virtual_branch(&project_id, &branch_id)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn apply_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: BranchId,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.apply_virtual_branch(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn unapply_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: BranchId,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.unapply_virtual_branch(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn unapply_ownership(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
ownership: BranchOwnershipClaims,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.unapply_ownership(&project_id, &ownership)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn reset_files(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
files: &str,
|
||||
) -> Result<(), Error> {
|
||||
// convert files to Vec<String>
|
||||
let files = files
|
||||
.split('\n')
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.reset_files(&project_id, &files)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn push_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
with_force: bool,
|
||||
) -> Result<(), Error> {
|
||||
let askpass_broker = handle.state::<AskpassBroker>();
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.push_virtual_branch(
|
||||
&project_id,
|
||||
&branch_id,
|
||||
with_force,
|
||||
Some((askpass_broker.inner().clone(), Some(branch_id))),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::UserError {
|
||||
code: Code::Unknown,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn can_apply_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
) -> Result<bool, Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.can_apply_virtual_branch(&project_id, &branch_id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn can_apply_remote_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch: git::RemoteRefname,
|
||||
) -> Result<bool, Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.can_apply_remote_branch(&project_id, &branch)
|
||||
.await
|
||||
.map_err(into_error)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_remote_commit_files(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
commit_oid: git::Oid,
|
||||
) -> Result<Vec<RemoteBranchFile>, Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.list_remote_commit_files(&project_id, commit_oid)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn reset_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
target_commit_oid: git::Oid,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.reset_virtual_branch(&project_id, &branch_id, target_commit_oid)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn cherry_pick_onto_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
target_commit_oid: git::Oid,
|
||||
) -> Result<Option<git::Oid>, Error> {
|
||||
let oid = handle
|
||||
.state::<Controller>()
|
||||
.cherry_pick(&project_id, &branch_id, target_commit_oid)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn amend_virtual_branch(
|
||||
handle: AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
ownership: BranchOwnershipClaims,
|
||||
) -> Result<git::Oid, Error> {
|
||||
let oid = handle
|
||||
.state::<Controller>()
|
||||
.amend(&project_id, &branch_id, &ownership)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn list_remote_branches(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
) -> Result<Vec<RemoteBranch>, Error> {
|
||||
let branches = handle
|
||||
.state::<Controller>()
|
||||
.list_remote_branches(&project_id)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn get_remote_branch_data(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
refname: git::Refname,
|
||||
) -> Result<RemoteBranchData, Error> {
|
||||
let branch_data = handle
|
||||
.state::<Controller>()
|
||||
.get_remote_branch_data(&project_id, &refname)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
let branch_data = handle
|
||||
.state::<assets::Proxy>()
|
||||
.proxy_remote_branch_data(branch_data)
|
||||
.await;
|
||||
Ok(branch_data)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn squash_branch_commit(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
target_commit_oid: git::Oid,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.squash(&project_id, &branch_id, target_commit_oid)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn fetch_from_target(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
action: Option<String>,
|
||||
) -> Result<BaseBranch, Error> {
|
||||
let askpass_broker = handle.state::<AskpassBroker>().inner().clone();
|
||||
let base_branch = handle
|
||||
.state::<Controller>()
|
||||
.fetch_from_target(
|
||||
&project_id,
|
||||
Some((
|
||||
askpass_broker,
|
||||
action.unwrap_or_else(|| "unknown".to_string()),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(base_branch)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle))]
|
||||
pub async fn move_commit(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
commit_oid: git::Oid,
|
||||
target_branch_id: BranchId,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.move_commit(&project_id, &target_branch_id, commit_oid)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// XXX(qix-): Is this command used?
|
||||
#[allow(dead_code)]
|
||||
pub async fn update_commit_message(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
branch_id: BranchId,
|
||||
commit_oid: git::Oid,
|
||||
message: &str,
|
||||
) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.update_commit_message(&project_id, &branch_id, commit_oid, message)
|
||||
.await
|
||||
.map_err(into_error)?;
|
||||
emit_vbranches(&handle, &project_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_vbranches(handle: &AppHandle, project_id: &projects::ProjectId) {
|
||||
if let Err(error) = handle
|
||||
.state::<watcher::Watchers>()
|
||||
.post(watcher::Event::CalculateVirtualBranches(*project_id))
|
||||
.await
|
||||
{
|
||||
tracing::error!(?error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,657 +0,0 @@
|
||||
use std::time;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
gb_repository,
|
||||
git::{self, diff},
|
||||
keys,
|
||||
project_repository::{self, LogUntil},
|
||||
projects::FetchResult,
|
||||
reader, sessions, users,
|
||||
virtual_branches::branch::BranchOwnershipClaims,
|
||||
};
|
||||
|
||||
use super::{
|
||||
branch, errors,
|
||||
integration::{update_gitbutler_integration, GITBUTLER_INTEGRATION_REFERENCE},
|
||||
target, BranchId, RemoteCommit,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BaseBranch {
|
||||
pub branch_name: String,
|
||||
pub remote_name: String,
|
||||
pub remote_url: String,
|
||||
pub base_sha: git::Oid,
|
||||
pub current_sha: git::Oid,
|
||||
pub behind: usize,
|
||||
pub upstream_commits: Vec<RemoteCommit>,
|
||||
pub recent_commits: Vec<RemoteCommit>,
|
||||
pub last_fetched_ms: Option<u128>,
|
||||
}
|
||||
|
||||
pub fn get_base_branch_data(
|
||||
gb_repository: &gb_repository::Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<Option<super::BaseBranch>, errors::GetBaseBranchDataError> {
|
||||
match gb_repository
|
||||
.default_target()
|
||||
.context("failed to get default target")?
|
||||
{
|
||||
None => Ok(None),
|
||||
Some(target) => {
|
||||
let base = target_to_base_branch(project_repository, &target)
|
||||
.context("failed to convert default target to base branch")?;
|
||||
Ok(Some(base))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn go_back_to_integration(
|
||||
gb_repository: &gb_repository::Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
default_target: &target::Target,
|
||||
) -> Result<super::BaseBranch, errors::SetBaseBranchError> {
|
||||
let statuses = project_repository
|
||||
.git_repository
|
||||
.statuses(Some(
|
||||
git2::StatusOptions::new()
|
||||
.show(git2::StatusShow::IndexAndWorkdir)
|
||||
.include_untracked(true),
|
||||
))
|
||||
.context("failed to get status")?;
|
||||
if !statuses.is_empty() {
|
||||
return Err(errors::SetBaseBranchError::DirtyWorkingDirectory);
|
||||
}
|
||||
|
||||
let latest_session = gb_repository
|
||||
.get_latest_session()?
|
||||
.context("no session found")?;
|
||||
let session_reader = sessions::Reader::open(gb_repository, &latest_session)?;
|
||||
|
||||
let all_virtual_branches = super::iterator::BranchIterator::new(&session_reader)
|
||||
.context("failed to create branch iterator")?
|
||||
.collect::<Result<Vec<super::branch::Branch>, reader::Error>>()
|
||||
.context("failed to read virtual branches")?;
|
||||
|
||||
let applied_virtual_branches = all_virtual_branches
|
||||
.iter()
|
||||
.filter(|branch| branch.applied)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let target_commit = project_repository
|
||||
.git_repository
|
||||
.find_commit(default_target.sha)
|
||||
.context("failed to find target commit")?;
|
||||
|
||||
let base_tree = target_commit
|
||||
.tree()
|
||||
.context("failed to get base tree from commit")?;
|
||||
let mut final_tree = target_commit
|
||||
.tree()
|
||||
.context("failed to get base tree from commit")?;
|
||||
for branch in &applied_virtual_branches {
|
||||
// merge this branches tree with our tree
|
||||
let branch_head = project_repository
|
||||
.git_repository
|
||||
.find_commit(branch.head)
|
||||
.context("failed to find branch head")?;
|
||||
let branch_tree = branch_head
|
||||
.tree()
|
||||
.context("failed to get branch head tree")?;
|
||||
let mut result = project_repository
|
||||
.git_repository
|
||||
.merge_trees(&base_tree, &final_tree, &branch_tree)
|
||||
.context("failed to merge")?;
|
||||
let final_tree_oid = result
|
||||
.write_tree_to(&project_repository.git_repository)
|
||||
.context("failed to write tree")?;
|
||||
final_tree = project_repository
|
||||
.git_repository
|
||||
.find_tree(final_tree_oid)
|
||||
.context("failed to find written tree")?;
|
||||
}
|
||||
|
||||
project_repository
|
||||
.git_repository
|
||||
.checkout_tree(&final_tree)
|
||||
.force()
|
||||
.checkout()
|
||||
.context("failed to checkout tree")?;
|
||||
|
||||
let base = target_to_base_branch(project_repository, default_target)?;
|
||||
update_gitbutler_integration(gb_repository, project_repository)?;
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
pub fn set_base_branch(
|
||||
gb_repository: &gb_repository::Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
target_branch_ref: &git::RemoteRefname,
|
||||
) -> Result<super::BaseBranch, errors::SetBaseBranchError> {
|
||||
let repo = &project_repository.git_repository;
|
||||
|
||||
// if target exists, and it is the same as the requested branch, we should go back
|
||||
if let Some(target) = gb_repository.default_target()? {
|
||||
if target.branch.eq(target_branch_ref) {
|
||||
return go_back_to_integration(gb_repository, project_repository, &target);
|
||||
}
|
||||
}
|
||||
|
||||
// lookup a branch by name
|
||||
let target_branch = match repo.find_branch(&target_branch_ref.clone().into()) {
|
||||
Ok(branch) => Ok(branch),
|
||||
Err(git::Error::NotFound(_)) => Err(errors::SetBaseBranchError::BranchNotFound(
|
||||
target_branch_ref.clone(),
|
||||
)),
|
||||
Err(error) => Err(errors::SetBaseBranchError::Other(error.into())),
|
||||
}?;
|
||||
|
||||
let remote = repo
|
||||
.find_remote(target_branch_ref.remote())
|
||||
.context(format!(
|
||||
"failed to find remote for branch {}",
|
||||
target_branch.name().unwrap()
|
||||
))?;
|
||||
let remote_url = remote
|
||||
.url()
|
||||
.context(format!(
|
||||
"failed to get remote url for {}",
|
||||
target_branch_ref.remote()
|
||||
))?
|
||||
.unwrap();
|
||||
|
||||
let target_branch_head = target_branch.peel_to_commit().context(format!(
|
||||
"failed to peel branch {} to commit",
|
||||
target_branch.name().unwrap()
|
||||
))?;
|
||||
|
||||
let current_head = repo.head().context("Failed to get HEAD reference")?;
|
||||
let current_head_commit = current_head
|
||||
.peel_to_commit()
|
||||
.context("Failed to peel HEAD reference to commit")?;
|
||||
|
||||
// calculate the commit as the merge-base between HEAD in project_repository and this target commit
|
||||
let target_commit_oid = repo
|
||||
.merge_base(current_head_commit.id(), target_branch_head.id())
|
||||
.context(format!(
|
||||
"Failed to calculate merge base between {} and {}",
|
||||
current_head_commit.id(),
|
||||
target_branch_head.id()
|
||||
))?;
|
||||
|
||||
let target = target::Target {
|
||||
branch: target_branch_ref.clone(),
|
||||
remote_url: remote_url.to_string(),
|
||||
sha: target_commit_oid,
|
||||
};
|
||||
|
||||
let target_writer = target::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create target writer")?;
|
||||
target_writer.write_default(&target)?;
|
||||
|
||||
let head_name: git::Refname = current_head
|
||||
.name()
|
||||
.context("Failed to get HEAD reference name")?;
|
||||
if !head_name
|
||||
.to_string()
|
||||
.eq(&GITBUTLER_INTEGRATION_REFERENCE.to_string())
|
||||
{
|
||||
// if there are any commits on the head branch or uncommitted changes in the working directory, we need to
|
||||
// put them into a virtual branch
|
||||
|
||||
let use_context = project_repository
|
||||
.project()
|
||||
.use_diff_context
|
||||
.unwrap_or(false);
|
||||
let context_lines = if use_context { 3_u32 } else { 0_u32 };
|
||||
let wd_diff = diff::workdir(repo, ¤t_head_commit.id(), context_lines)?;
|
||||
let wd_diff = diff::diff_files_to_hunks(&wd_diff);
|
||||
if !wd_diff.is_empty() || current_head_commit.id() != target.sha {
|
||||
let hunks_by_filepath =
|
||||
super::virtual_hunks_by_filepath(&project_repository.project().path, &wd_diff);
|
||||
|
||||
// assign ownership to the branch
|
||||
let ownership = hunks_by_filepath.values().flatten().fold(
|
||||
BranchOwnershipClaims::default(),
|
||||
|mut ownership, hunk| {
|
||||
ownership.put(
|
||||
&format!("{}:{}", hunk.file_path.display(), hunk.id)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
ownership
|
||||
},
|
||||
);
|
||||
|
||||
let now_ms = time::UNIX_EPOCH
|
||||
.elapsed()
|
||||
.context("failed to get elapsed time")?
|
||||
.as_millis();
|
||||
|
||||
let (upstream, upstream_head) = if let git::Refname::Local(head_name) = &head_name {
|
||||
let upstream_name = target_branch_ref.with_branch(head_name.branch());
|
||||
if upstream_name.eq(target_branch_ref) {
|
||||
(None, None)
|
||||
} else {
|
||||
match repo.find_reference(&git::Refname::from(&upstream_name)) {
|
||||
Ok(upstream) => {
|
||||
let head = upstream
|
||||
.peel_to_commit()
|
||||
.map(|commit| commit.id())
|
||||
.context(format!(
|
||||
"failed to peel upstream {} to commit",
|
||||
upstream.name().unwrap()
|
||||
))?;
|
||||
Ok((Some(upstream_name), Some(head)))
|
||||
}
|
||||
Err(git::Error::NotFound(_)) => Ok((None, None)),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
.context(format!("failed to find upstream for {}", head_name))?
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut branch = branch::Branch {
|
||||
id: BranchId::generate(),
|
||||
name: head_name.to_string().replace("refs/heads/", ""),
|
||||
notes: String::new(),
|
||||
applied: true,
|
||||
upstream,
|
||||
upstream_head,
|
||||
created_timestamp_ms: now_ms,
|
||||
updated_timestamp_ms: now_ms,
|
||||
head: current_head_commit.id(),
|
||||
tree: super::write_tree_onto_commit(
|
||||
project_repository,
|
||||
current_head_commit.id(),
|
||||
&wd_diff,
|
||||
)?,
|
||||
ownership,
|
||||
order: 0,
|
||||
selected_for_changes: None,
|
||||
};
|
||||
|
||||
let branch_writer =
|
||||
branch::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create branch writer")?;
|
||||
branch_writer.write(&mut branch)?;
|
||||
}
|
||||
}
|
||||
|
||||
set_exclude_decoration(project_repository)?;
|
||||
|
||||
super::integration::update_gitbutler_integration(gb_repository, project_repository)?;
|
||||
|
||||
let base = target_to_base_branch(project_repository, &target)?;
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn set_exclude_decoration(project_repository: &project_repository::Repository) -> Result<()> {
|
||||
let repo = &project_repository.git_repository;
|
||||
let mut config = repo.config()?;
|
||||
config
|
||||
.set_multivar("log.excludeDecoration", "refs/gitbutler", "refs/gitbutler")
|
||||
.context("failed to set log.excludeDecoration")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _print_tree(repo: &git2::Repository, tree: &git2::Tree) -> Result<()> {
|
||||
println!("tree id: {}", tree.id());
|
||||
for entry in tree {
|
||||
println!(
|
||||
" entry: {} {}",
|
||||
entry.name().unwrap_or_default(),
|
||||
entry.id()
|
||||
);
|
||||
// get entry contents
|
||||
let object = entry.to_object(repo).context("failed to get object")?;
|
||||
let blob = object.as_blob().context("failed to get blob")?;
|
||||
// convert content to string
|
||||
if let Ok(content) = std::str::from_utf8(blob.content()) {
|
||||
println!(" blob: {}", content);
|
||||
} else {
|
||||
println!(" blob: BINARY");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// try to update the target branch
|
||||
// this means that we need to:
|
||||
// determine if what the target branch is now pointing to is mergeable with our current working directory
|
||||
// merge the target branch into our current working directory
|
||||
// update the target sha
|
||||
pub fn update_base_branch(
|
||||
gb_repository: &gb_repository::Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
user: Option<&users::User>,
|
||||
signing_key: Option<&keys::PrivateKey>,
|
||||
) -> Result<(), errors::UpdateBaseBranchError> {
|
||||
if project_repository.is_resolving() {
|
||||
return Err(errors::UpdateBaseBranchError::Conflict(
|
||||
errors::ProjectConflictError {
|
||||
project_id: project_repository.project().id,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// look up the target and see if there is a new oid
|
||||
let target = gb_repository
|
||||
.default_target()
|
||||
.context("failed to get default target")?
|
||||
.ok_or_else(|| {
|
||||
errors::UpdateBaseBranchError::DefaultTargetNotSet(errors::DefaultTargetNotSetError {
|
||||
project_id: project_repository.project().id,
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo = &project_repository.git_repository;
|
||||
let target_branch = repo
|
||||
.find_branch(&target.branch.clone().into())
|
||||
.context(format!("failed to find branch {}", target.branch))?;
|
||||
|
||||
let new_target_commit = target_branch
|
||||
.peel_to_commit()
|
||||
.context(format!("failed to peel branch {} to commit", target.branch))?;
|
||||
|
||||
// if the target has not changed, do nothing
|
||||
if new_target_commit.id() == target.sha {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ok, target has changed, so now we need to merge it into our current work and update our branches
|
||||
|
||||
// get tree from new target
|
||||
let new_target_tree = new_target_commit
|
||||
.tree()
|
||||
.context("failed to get new target commit tree")?;
|
||||
|
||||
let old_target_tree = repo
|
||||
.find_commit(target.sha)
|
||||
.and_then(|commit| commit.tree())
|
||||
.context(format!(
|
||||
"failed to get old target commit tree {}",
|
||||
target.sha
|
||||
))?;
|
||||
|
||||
let branch_writer = branch::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create branch writer")?;
|
||||
|
||||
let use_context = project_repository
|
||||
.project()
|
||||
.use_diff_context
|
||||
.unwrap_or(false);
|
||||
let context_lines = if use_context { 3_u32 } else { 0_u32 };
|
||||
|
||||
// try to update every branch
|
||||
let updated_vbranches = super::get_status_by_branch(gb_repository, project_repository)?
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(branch, _)| branch)
|
||||
.map(
|
||||
|mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
let branch_tree = repo.find_tree(branch.tree)?;
|
||||
|
||||
let branch_head_commit = repo.find_commit(branch.head).context(format!(
|
||||
"failed to find commit {} for branch {}",
|
||||
branch.head, branch.id
|
||||
))?;
|
||||
let branch_head_tree = branch_head_commit.tree().context(format!(
|
||||
"failed to find tree for commit {} for branch {}",
|
||||
branch.head, branch.id
|
||||
))?;
|
||||
|
||||
let result_integrated_detected =
|
||||
|mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
// branch head tree is the same as the new target tree.
|
||||
// meaning we can safely use the new target commit as the branch head.
|
||||
|
||||
branch.head = new_target_commit.id();
|
||||
|
||||
// it also means that the branch is fully integrated into the target.
|
||||
// disconnect it from the upstream
|
||||
branch.upstream = None;
|
||||
branch.upstream_head = None;
|
||||
|
||||
let non_commited_files = diff::trees(
|
||||
&project_repository.git_repository,
|
||||
&branch_head_tree,
|
||||
&branch_tree,
|
||||
context_lines,
|
||||
)?;
|
||||
if non_commited_files.is_empty() {
|
||||
// if there are no commited files, then the branch is fully merged
|
||||
// and we can delete it.
|
||||
branch_writer.delete(&branch)?;
|
||||
project_repository.delete_branch_reference(&branch)?;
|
||||
Ok(None)
|
||||
} else {
|
||||
branch_writer.write(&mut branch)?;
|
||||
Ok(Some(branch))
|
||||
}
|
||||
};
|
||||
|
||||
if branch_head_tree.id() == new_target_tree.id() {
|
||||
return result_integrated_detected(branch);
|
||||
}
|
||||
|
||||
// try to merge branch head with new target
|
||||
let mut branch_tree_merge_index = repo
|
||||
.merge_trees(&old_target_tree, &branch_tree, &new_target_tree)
|
||||
.context(format!("failed to merge trees for branch {}", branch.id))?;
|
||||
|
||||
if branch_tree_merge_index.has_conflicts() {
|
||||
// branch tree conflicts with new target, unapply branch for now. we'll handle it later, when user applies it back.
|
||||
branch.applied = false;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
let branch_merge_index_tree_oid = branch_tree_merge_index.write_tree_to(repo)?;
|
||||
|
||||
if branch_merge_index_tree_oid == new_target_tree.id() {
|
||||
return result_integrated_detected(branch);
|
||||
}
|
||||
|
||||
if branch.head == target.sha {
|
||||
// there are no commits on the branch, so we can just update the head to the new target and calculate the new tree
|
||||
branch.head = new_target_commit.id();
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
let mut branch_head_merge_index = repo
|
||||
.merge_trees(&old_target_tree, &branch_head_tree, &new_target_tree)
|
||||
.context(format!(
|
||||
"failed to merge head tree for branch {}",
|
||||
branch.id
|
||||
))?;
|
||||
|
||||
if branch_head_merge_index.has_conflicts() {
|
||||
// branch commits conflict with new target, make sure the branch is
|
||||
// unapplied. conflicts witll be dealt with when applying it back.
|
||||
branch.applied = false;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
// branch commits do not conflict with new target, so lets merge them
|
||||
let branch_head_merge_tree_oid = branch_head_merge_index
|
||||
.write_tree_to(repo)
|
||||
.context(format!(
|
||||
"failed to write head merge index for {}",
|
||||
branch.id
|
||||
))?;
|
||||
|
||||
let ok_with_force_push = project_repository.project().ok_with_force_push;
|
||||
|
||||
let result_merge = |mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
// branch was pushed to upstream, and user doesn't like force pushing.
|
||||
// create a merge commit to avoid the need of force pushing then.
|
||||
let branch_head_merge_tree = repo
|
||||
.find_tree(branch_head_merge_tree_oid)
|
||||
.context("failed to find tree")?;
|
||||
|
||||
let new_target_head = project_repository
|
||||
.commit(
|
||||
user,
|
||||
format!(
|
||||
"Merged {}/{} into {}",
|
||||
target.branch.remote(),
|
||||
target.branch.branch(),
|
||||
branch.name
|
||||
)
|
||||
.as_str(),
|
||||
&branch_head_merge_tree,
|
||||
&[&branch_head_commit, &new_target_commit],
|
||||
signing_key,
|
||||
)
|
||||
.context("failed to commit merge")?;
|
||||
|
||||
branch.head = new_target_head;
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
Ok(Some(branch))
|
||||
};
|
||||
|
||||
if branch.upstream.is_some() && !ok_with_force_push {
|
||||
return result_merge(branch);
|
||||
}
|
||||
|
||||
// branch was not pushed to upstream yet. attempt a rebase,
|
||||
let (_, committer) = project_repository.git_signatures(user)?;
|
||||
let mut rebase_options = git2::RebaseOptions::new();
|
||||
rebase_options.quiet(true);
|
||||
rebase_options.inmemory(true);
|
||||
let mut rebase = repo
|
||||
.rebase(
|
||||
Some(branch.head),
|
||||
Some(new_target_commit.id()),
|
||||
None,
|
||||
Some(&mut rebase_options),
|
||||
)
|
||||
.context("failed to rebase")?;
|
||||
|
||||
let mut rebase_success = true;
|
||||
// check to see if these commits have already been pushed
|
||||
let mut last_rebase_head = branch.head;
|
||||
while rebase.next().is_some() {
|
||||
let index = rebase
|
||||
.inmemory_index()
|
||||
.context("failed to get inmemory index")?;
|
||||
if index.has_conflicts() {
|
||||
rebase_success = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(commit_id) = rebase.commit(None, &committer.clone().into(), None) {
|
||||
last_rebase_head = commit_id.into();
|
||||
} else {
|
||||
rebase_success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if rebase_success {
|
||||
// rebase worked out, rewrite the branch head
|
||||
rebase.finish(None).context("failed to finish rebase")?;
|
||||
branch.head = last_rebase_head;
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
// rebase failed, do a merge commit
|
||||
rebase.abort().context("failed to abort rebase")?;
|
||||
|
||||
result_merge(branch)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// ok, now all the problematic branches have been unapplied
|
||||
// now we calculate and checkout new tree for the working directory
|
||||
|
||||
let final_tree = updated_vbranches
|
||||
.iter()
|
||||
.filter(|branch| branch.applied)
|
||||
.fold(new_target_commit.tree(), |final_tree, branch| {
|
||||
let final_tree = final_tree?;
|
||||
let branch_tree = repo.find_tree(branch.tree)?;
|
||||
let mut merge_result = repo.merge_trees(&new_target_tree, &final_tree, &branch_tree)?;
|
||||
let final_tree_oid = merge_result.write_tree_to(repo)?;
|
||||
repo.find_tree(final_tree_oid)
|
||||
})
|
||||
.context("failed to calculate final tree")?;
|
||||
|
||||
repo.checkout_tree(&final_tree).force().checkout().context(
|
||||
"failed to checkout index, this should not have happened, we should have already detected this",
|
||||
)?;
|
||||
|
||||
// write new target oid
|
||||
let target_writer = target::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create target writer")?;
|
||||
target_writer.write_default(&target::Target {
|
||||
sha: new_target_commit.id(),
|
||||
..target
|
||||
})?;
|
||||
|
||||
super::integration::update_gitbutler_integration(gb_repository, project_repository)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn target_to_base_branch(
|
||||
project_repository: &project_repository::Repository,
|
||||
target: &target::Target,
|
||||
) -> Result<super::BaseBranch> {
|
||||
let repo = &project_repository.git_repository;
|
||||
let branch = repo.find_branch(&target.branch.clone().into())?;
|
||||
let commit = branch.peel_to_commit()?;
|
||||
let oid = commit.id();
|
||||
|
||||
// gather a list of commits between oid and target.sha
|
||||
let upstream_commits = project_repository
|
||||
.log(oid, project_repository::LogUntil::Commit(target.sha))
|
||||
.context("failed to get upstream commits")?
|
||||
.iter()
|
||||
.map(super::commit_to_remote_commit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// get some recent commits
|
||||
let recent_commits = project_repository
|
||||
.log(target.sha, LogUntil::Take(20))
|
||||
.context("failed to get recent commits")?
|
||||
.iter()
|
||||
.map(super::commit_to_remote_commit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let base = super::BaseBranch {
|
||||
branch_name: format!("{}/{}", target.branch.remote(), target.branch.branch()),
|
||||
remote_name: target.branch.remote().to_string(),
|
||||
remote_url: target.remote_url.clone(),
|
||||
base_sha: target.sha,
|
||||
current_sha: oid,
|
||||
behind: upstream_commits.len(),
|
||||
upstream_commits,
|
||||
recent_commits,
|
||||
last_fetched_ms: project_repository
|
||||
.project()
|
||||
.project_data_last_fetch
|
||||
.as_ref()
|
||||
.map(FetchResult::timestamp)
|
||||
.copied()
|
||||
.map(|t| t.duration_since(time::UNIX_EPOCH).unwrap().as_millis()),
|
||||
};
|
||||
Ok(base)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user