integrate library into app

Note that small `commands` modules are now inlined for ease of use.
This commit is contained in:
Sebastian Thiel 2024-03-29 19:23:20 +01:00
parent 3b89ed50f9
commit 2dbdc6ea99
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
223 changed files with 1538 additions and 33521 deletions

1
Cargo.lock generated
View File

@ -1973,6 +1973,7 @@ dependencies = [
"futures",
"git2",
"git2-hooks",
"gitbutler",
"gitbutler-git",
"governor",
"itertools 0.12.1",

View File

@ -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"

View File

@ -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;

View File

@ -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)]

View File

@ -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(

View File

@ -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()
}

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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`);

View File

@ -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`);

View File

@ -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
);

View File

@ -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`);

View File

@ -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;

View File

@ -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;

View File

@ -1 +0,0 @@
CREATE INDEX `sessions_project_id_id_index` ON `sessions` (`project_id`, `id`);

View File

@ -1,2 +0,0 @@
DROP TABLE files;
DROP TABLE contents;

View File

@ -1 +0,0 @@
DROP TABLE bookmarks;

View File

@ -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());
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
",
)?)
}

View File

@ -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,
}

View File

@ -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>())
}
}

View File

@ -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)
}

View File

@ -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<_, _>>>()?)
}
}

View File

@ -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(())
}
}

View File

@ -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
}
}
}
}
}

View File

@ -1,7 +1,7 @@
use anyhow::{Context, Result};
use tauri::{AppHandle, Manager};
use crate::{
use gitbutler::{
deltas,
projects::ProjectId,
reader,

View File

@ -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)
}

View File

@ -1,3 +0,0 @@
mod repository;
pub use repository::{RemoteError, Repository};

View File

@ -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, &current_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(&current_refname)
{
Result::Ok(reference) => {
let last_commit = reference.peel_to_commit()?;
let new_commit = gb_repository.git_repository.commit(
Some(&current_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(&current_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),
}

View File

@ -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::*;

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()),
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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>;

View File

@ -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,
}
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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),
}

View File

@ -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()),
}
}
}
}

View File

@ -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()
}
}

View File

@ -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()))
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()),
}
}

View File

@ -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()
}
}

View File

@ -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())
}
}
}

View File

@ -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())
}
}

View File

@ -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);
}
}
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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),
}

View File

@ -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)
}
}

View File

@ -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(())
}
}

View File

@ -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;

View File

@ -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)
}
}

View File

@ -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(),

View File

@ -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
}
}

View File

@ -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;

View File

@ -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)
}
}

View File

@ -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(())
}

View File

@ -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
}
}

View File

@ -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))
}

View File

@ -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(),
})
}
}

View File

@ -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(),
})
}

View File

@ -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),
}

View File

@ -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")
}
}

View File

@ -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(())
}
}

View File

@ -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)
}
}

View File

@ -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();

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
",
)?)
}

View File

@ -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,
}
}
}

View File

@ -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))
}
}

View File

@ -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,
},
})
}
}

View File

@ -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(())
}
}

View File

@ -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(())
}
}
}

View File

@ -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(())
}
}

View File

@ -1 +0,0 @@
pub mod default_true;

View File

@ -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
}
}

View File

@ -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(())
}
}

View File

@ -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(())
}

View File

@ -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),
}

View File

@ -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(())
}
}

View File

@ -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)
}
}
}

View File

@ -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);
}
}
}

View File

@ -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, &current_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