chore(app): move library-portion to top-level src/ folder.

This allows the tauri-specific parts to remain in the `app` crate,
which will help to eventually release a `cli` crate as well.
This commit is contained in:
Sebastian Thiel 2024-03-29 10:04:26 +01:00
parent 326a5a00b3
commit bc2fff968c
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
163 changed files with 32758 additions and 6 deletions

61
Cargo.lock generated
View File

@ -1906,6 +1906,67 @@ dependencies = [
"thiserror",
]
[[package]]
name = "gitbutler"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"backoff",
"backtrace",
"bstr 1.9.1",
"byteorder",
"chrono",
"console-subscriber",
"diffy",
"filetime",
"fslock",
"futures",
"git2",
"git2-hooks",
"gitbutler-git",
"governor",
"itertools 0.12.1",
"lazy_static",
"log",
"md5",
"nonzero_ext",
"notify",
"notify-debouncer-full",
"num_cpus",
"once_cell",
"pretty_assertions",
"r2d2",
"r2d2_sqlite",
"rand 0.8.5",
"refinery",
"regex",
"reqwest 0.12.2",
"resolve-path",
"rusqlite",
"serde",
"serde_json",
"sha1",
"sha2",
"similar",
"slug",
"ssh-key",
"ssh2",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"toml 0.8.12",
"tracing",
"tracing-appender",
"tracing-subscriber",
"url",
"urlencoding",
"uuid",
"walkdir",
"zip",
]
[[package]]
name = "gitbutler-app"
version = "0.0.0"

View File

@ -1,3 +1,85 @@
[package]
name = "gitbutler"
version = "0.0.0"
edition = "2021"
rust-version = "1.57"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[lib]
doctest = false
[dev-dependencies]
once_cell = "1.19"
pretty_assertions = "1.4"
[dependencies]
toml = "0.8.12"
anyhow = "1.0.81"
async-trait = "0.1.79"
backoff = "0.4.0"
backtrace = { version = "0.3.71", optional = true }
bstr = "1.9.1"
byteorder = "1.5.0"
chrono = { version = "0.4.37", features = ["serde"] }
console-subscriber = "0.2.0"
diffy = "0.3.0"
filetime = "0.2.23"
fslock = "0.2.1"
futures = "0.3"
git2.workspace = true
git2-hooks = "0.3"
governor = "0.6.3"
itertools = "0.12"
lazy_static = "1.4.0"
md5 = "0.7.0"
nonzero_ext = "0.3.0"
notify = { version = "6.0.1" }
notify-debouncer-full = "0.3.1"
num_cpus = "1.16.0"
once_cell = "1.19"
r2d2 = "0.8.10"
r2d2_sqlite = "0.22.0"
rand = "0.8.5"
refinery = { version = "0.8", features = [ "rusqlite" ] }
regex = "1.10"
reqwest = { version = "0.12.2", features = ["json"] }
resolve-path = "0.1.0"
rusqlite.workspace = true
serde.workspace = true
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
sha1 = "0.10.6"
sha2 = "0.10.8"
similar = { version = "2.4.0", features = ["unicode"] }
slug = "0.1.5"
ssh-key = { version = "0.6.5", features = [ "alloc", "ed25519" ] }
ssh2 = { version = "0.9.4", features = ["vendored-openssl"] }
log = "^0.4"
thiserror.workspace = true
tokio = { workspace = true, features = [ "full", "sync" ] }
tokio-util = "0.7.10"
tracing = "0.1.40"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.17"
url = { version = "2.5", features = ["serde"] }
urlencoding = "2.1.3"
uuid.workspace = true
walkdir = "2.5.0"
zip = "0.6.5"
tempfile = "3.10"
gitbutler-git = { path = "gitbutler-git" }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is a URL
default = ["error-context"]
error-context = ["dep:backtrace"]
[lints.clippy]
all = "deny"
perf = "deny"
correctness = "deny"
[workspace]
members = [
"gitbutler-app",
@ -8,7 +90,7 @@ resolver = "2"
[workspace.dependencies]
git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"] }
uuid = "1.8.0"
uuid = { version = "1.8.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0.58"
rusqlite = { version = "0.29.0", features = [ "bundled", "blob" ] }

View File

@ -16,13 +16,17 @@
pub mod analytics;
pub mod app;
pub mod askpass;
pub mod assets;
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 deltas;
pub mod error;
pub mod events;
pub mod fs;
pub mod gb_repository;
pub mod git;
@ -30,8 +34,6 @@ pub mod github;
pub mod id;
pub mod keys;
pub mod lock;
pub mod logs;
pub mod menu;
pub mod path;
pub mod project_repository;
pub mod projects;
@ -43,7 +45,6 @@ pub mod storage;
pub mod types;
pub mod users;
pub mod virtual_branches;
pub mod watcher;
#[cfg(target_os = "windows")]
pub mod windows;
pub mod writer;

63
src/askpass.rs Normal file
View File

@ -0,0 +1,63 @@
use std::{collections::HashMap, sync::Arc};
use serde::Serialize;
use tokio::sync::{oneshot, Mutex};
use crate::id::Id;
use crate::virtual_branches::BranchId;
pub struct AskpassRequest {
sender: oneshot::Sender<Option<String>>,
}
#[derive(Debug, Clone, serde::Serialize)]
// This is needed to end up with a struct with either `branch_id` or `action`
#[serde(untagged)]
pub enum Context {
Push { branch_id: Option<BranchId> },
Fetch { action: String },
}
#[derive(Clone)]
pub struct AskpassBroker {
pending_requests: Arc<Mutex<HashMap<Id<AskpassRequest>, AskpassRequest>>>,
submit_prompt_event: Arc<dyn Fn(PromptEvent<Context>) + Send + Sync>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PromptEvent<C: Serialize + Clone> {
id: Id<AskpassRequest>,
prompt: String,
context: C,
}
impl AskpassBroker {
pub fn init(submit_prompt: impl Fn(PromptEvent<Context>) + Send + Sync + 'static) -> Self {
Self {
pending_requests: Arc::new(Mutex::new(HashMap::new())),
submit_prompt_event: Arc::new(submit_prompt),
}
}
pub async fn submit_prompt(&self, prompt: String, context: Context) -> Option<String> {
let (sender, receiver) = oneshot::channel();
let id = Id::generate();
let request = AskpassRequest { sender };
self.pending_requests.lock().await.insert(id, request);
(self.submit_prompt_event)(PromptEvent {
id,
prompt,
context,
});
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);
}
}
}

204
src/assets.rs Normal file
View File

@ -0,0 +1,204 @@
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()
}

48
src/database.rs Normal file
View File

@ -0,0 +1,48 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,8 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,28 @@
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

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

View File

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

View File

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

45
src/dedup.rs Normal file
View File

@ -0,0 +1,45 @@
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());
}
}

15
src/deltas.rs Normal file
View File

@ -0,0 +1,15 @@
mod controller;
mod delta;
mod document;
mod reader;
mod writer;
pub mod database;
pub mod operations;
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;

33
src/deltas/controller.rs Normal file
View File

@ -0,0 +1,33 @@
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)
}
}

122
src/deltas/database.rs Normal file
View File

@ -0,0 +1,122 @@
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
",
)?)
}

9
src/deltas/delta.rs Normal file
View File

@ -0,0 +1,9 @@
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,
}

85
src/deltas/document.rs Normal file
View File

@ -0,0 +1,85 @@
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>())
}
}

116
src/deltas/operations.rs Normal file
View File

@ -0,0 +1,116 @@
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)
}

89
src/deltas/reader.rs Normal file
View File

@ -0,0 +1,89 @@
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<_, _>>>()?)
}
}

73
src/deltas/writer.rs Normal file
View File

@ -0,0 +1,73 @@
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(())
}
}

414
src/error.rs Normal file
View File

@ -0,0 +1,414 @@
#[cfg(feature = "sentry")]
mod sentry;
pub use legacy::*;
pub mod gb {
#[cfg(feature = "error-context")]
pub use error_context::*;
#[cfg(feature = "error-context")]
mod error_context {
use super::{ErrorKind, Result, WithContext};
use backtrace::Backtrace;
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct Context {
pub backtrace: Backtrace,
pub caused_by: Option<Box<ErrorContext>>,
pub vars: BTreeMap<String, String>,
}
impl Default for Context {
fn default() -> Self {
Self {
backtrace: Backtrace::new_unresolved(),
caused_by: None,
vars: BTreeMap::default(),
}
}
}
#[derive(Debug)]
pub struct ErrorContext {
error: ErrorKind,
context: Context,
}
impl core::fmt::Display for ErrorContext {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
self.error.fmt(f)
}
}
impl std::error::Error for ErrorContext {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.context
.caused_by
.as_ref()
.map(|e| e as &dyn std::error::Error)
}
fn provide<'a>(&'a self, request: &mut std::error::Request<'a>) {
if request.would_be_satisfied_by_ref_of::<Backtrace>() {
request.provide_ref(&self.context.backtrace);
}
}
}
impl ErrorContext {
#[inline]
pub fn error(&self) -> &ErrorKind {
&self.error
}
#[inline]
pub fn context(&self) -> &Context {
&self.context
}
pub fn into_owned(self) -> (ErrorKind, Context) {
(self.error, self.context)
}
}
impl<E: Into<ErrorContext>> WithContext<ErrorContext> for E {
fn add_err_context<K: Into<String>, V: Into<String>>(
self,
name: K,
value: V,
) -> ErrorContext {
let mut e = self.into();
e.context.vars.insert(name.into(), value.into());
e
}
fn wrap_err<K: Into<ErrorKind>>(self, error: K) -> ErrorContext {
let mut new_err = ErrorContext {
error: error.into(),
context: Context::default(),
};
new_err.context.caused_by = Some(Box::new(self.into()));
new_err
}
}
impl<T, E> WithContext<Result<T>> for std::result::Result<T, E>
where
E: Into<ErrorKind>,
{
#[inline]
fn add_err_context<K: Into<String>, V: Into<String>>(
self,
name: K,
value: V,
) -> Result<T> {
self.map_err(|e| {
ErrorContext {
error: e.into(),
context: Context::default(),
}
.add_err_context(name, value)
})
}
#[inline]
fn wrap_err<K: Into<ErrorKind>>(self, error: K) -> Result<T> {
self.map_err(|e| {
ErrorContext {
error: e.into(),
context: Context::default(),
}
.wrap_err(error)
})
}
}
#[cfg(feature = "error-context")]
impl serde::Serialize for ErrorContext {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(None)?;
let mut current = Some(self);
while let Some(err) = current {
seq.serialize_element(&err.error)?;
current = err.context.caused_by.as_deref();
}
seq.end()
}
}
impl From<ErrorKind> for ErrorContext {
fn from(error: ErrorKind) -> Self {
Self {
error,
context: Context::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_context() {
fn low_level_io() -> std::result::Result<(), std::io::Error> {
Err(std::io::Error::new(std::io::ErrorKind::Other, "oh no!"))
}
fn app_level_io() -> Result<()> {
low_level_io().add_err_context("foo", "bar")?;
unreachable!();
}
use std::error::Error;
let r = app_level_io();
assert!(r.is_err());
let e = r.unwrap_err();
assert_eq!(e.context().vars.get("foo"), Some(&"bar".to_string()));
assert!(e.source().is_none());
assert!(e.to_string().starts_with("io.other-error:"));
}
}
}
pub trait WithContext<R> {
fn add_err_context<K: Into<String>, V: Into<String>>(self, name: K, value: V) -> R;
fn wrap_err<E: Into<ErrorKind>>(self, error: E) -> R;
}
#[cfg(not(feature = "error-context"))]
pub struct Context;
pub trait ErrorCode {
fn code(&self) -> String;
fn message(&self) -> String;
}
#[derive(Debug, thiserror::Error)]
pub enum ErrorKind {
Io(#[from] ::std::io::Error),
Git(#[from] ::git2::Error),
CommonDirNotAvailable(String),
}
impl ErrorCode for std::io::Error {
fn code(&self) -> String {
slug::slugify(self.kind().to_string())
}
fn message(&self) -> String {
self.to_string()
}
}
impl ErrorCode for git2::Error {
fn code(&self) -> String {
slug::slugify(format!("{:?}", self.class()))
}
fn message(&self) -> String {
self.to_string()
}
}
impl ErrorCode for ErrorKind {
fn code(&self) -> String {
match self {
ErrorKind::Io(e) => format!("io.{}", <std::io::Error as ErrorCode>::code(e)),
ErrorKind::Git(e) => format!("git.{}", <git2::Error as ErrorCode>::code(e)),
ErrorKind::CommonDirNotAvailable(_) => "no-common-dir".to_string(),
}
}
fn message(&self) -> String {
match self {
ErrorKind::Io(e) => <std::io::Error as ErrorCode>::message(e),
ErrorKind::Git(e) => <git2::Error as ErrorCode>::message(e),
ErrorKind::CommonDirNotAvailable(s) => format!("{s} is not available"),
}
}
}
impl core::fmt::Display for ErrorKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
format!(
"{}: {}",
<Self as ErrorCode>::code(self),
<Self as ErrorCode>::message(self)
)
.fmt(f)
}
}
#[cfg(not(feature = "error-context"))]
pub type Error = ErrorKind;
#[cfg(feature = "error-context")]
pub type Error = ErrorContext;
pub type Result<T> = ::std::result::Result<T, Error>;
#[cfg(not(feature = "error-context"))]
impl ErrorKind {
#[inline]
pub fn error(&self) -> &Error {
self
}
#[inline]
pub fn context(&self) -> Option<&Context> {
None
}
}
#[cfg(not(feature = "error-context"))]
impl WithContext<ErrorKind> for ErrorKind {
#[inline]
fn add_err_context<K: Into<String>, V: Into<String>>(self, _name: K, _value: V) -> Error {
self
}
#[inline]
fn wrap_err(self, _error: Error) -> Error {
self
}
}
#[cfg(not(feature = "error-context"))]
impl<T, E> WithContext<std::result::Result<T, E>> for std::result::Result<T, E> {
#[inline]
fn add_err_context<K: Into<String>, V: Into<String>>(
self,
_name: K,
_value: V,
) -> std::result::Result<T, E> {
self
}
#[inline]
fn wrap_err(self, _error: Error) -> std::result::Result<T, E> {
self
}
}
#[cfg(feature = "error-context")]
impl serde::Serialize for ErrorKind {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeTuple;
let mut seq = serializer.serialize_tuple(2)?;
seq.serialize_element(&self.code())?;
seq.serialize_element(&self.message())?;
seq.end()
}
}
}
//#[deprecated(
// note = "the types in the error::legacy::* module are deprecated; use error::gb::Error and error::gb::Result instead"
//)]
mod legacy {
use core::fmt;
use crate::{keys, projects, users};
use serde::{ser::SerializeMap, Serialize};
#[derive(Debug)]
pub enum Code {
Unknown,
Validation,
Projects,
Branches,
ProjectGitAuth,
ProjectGitRemote,
ProjectConflict,
ProjectHead,
Menu,
PreCommitHook,
CommitMsgHook,
}
impl fmt::Display for Code {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Code::Menu => write!(f, "errors.menu"),
Code::Unknown => write!(f, "errors.unknown"),
Code::Validation => write!(f, "errors.validation"),
Code::Projects => write!(f, "errors.projects"),
Code::Branches => write!(f, "errors.branches"),
Code::ProjectGitAuth => write!(f, "errors.projects.git.auth"),
Code::ProjectGitRemote => write!(f, "errors.projects.git.remote"),
Code::ProjectHead => write!(f, "errors.projects.head"),
Code::ProjectConflict => write!(f, "errors.projects.conflict"),
//TODO: rename js side to be more precise what kind of hook error this is
Code::PreCommitHook => write!(f, "errors.hook"),
Code::CommitMsgHook => write!(f, "errors.hooks.commit.msg"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("[{code}]: {message}")]
UserError { code: Code, message: String },
#[error("[errors.unknown]: Something went wrong")]
Unknown,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let (code, message) = match self {
Error::UserError { code, message } => (code.to_string(), message.to_string()),
Error::Unknown => (
Code::Unknown.to_string(),
"Something went wrong".to_string(),
),
};
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("code", &code)?;
map.serialize_entry("message", &message)?;
map.end()
}
}
impl From<anyhow::Error> for Error {
fn from(error: anyhow::Error) -> Self {
tracing::error!(?error);
Error::Unknown
}
}
impl From<keys::GetOrCreateError> for Error {
fn from(error: keys::GetOrCreateError) -> Self {
tracing::error!(?error);
Error::Unknown
}
}
impl From<users::GetError> for Error {
fn from(error: users::GetError) -> Self {
tracing::error!(?error);
Error::Unknown
}
}
impl From<projects::controller::GetError> for Error {
fn from(error: projects::controller::GetError) -> Self {
tracing::error!(?error);
Error::Unknown
}
}
}

89
src/error/sentry.rs Normal file
View File

@ -0,0 +1,89 @@
use crate::error::gb::{ErrorCode, ErrorContext};
use sentry::{
protocol::{value::Map, Event, Exception, Value},
types::Uuid,
};
use std::collections::BTreeMap;
pub trait SentrySender {
fn send_to_sentry(self) -> Uuid;
}
impl<E: Into<ErrorContext>> SentrySender for E {
fn send_to_sentry(self) -> Uuid {
let sentry_event = self.into().into();
sentry::capture_event(sentry_event)
}
}
trait PopulateException {
fn populate_exception(
self,
exceptions: &mut Vec<Exception>,
vars: &mut BTreeMap<String, Value>,
);
}
impl PopulateException for ErrorContext {
fn populate_exception(
self,
exceptions: &mut Vec<Exception>,
vars: &mut BTreeMap<String, Value>,
) {
let (error, mut context) = self.into_owned();
let mut exc = Exception {
ty: error.code(),
value: Some(error.message()),
..Exception::default()
};
if let Some(cause) = context.caused_by {
cause.populate_exception(exceptions, vars);
}
// We don't resolve at capture time because it can DRASTICALLY
// slow down the application (can take up to 0.5s to resolve
// a *single* frame). We do it here, only when a Sentry event
// is being created.
context.backtrace.resolve();
exc.stacktrace =
sentry::integrations::backtrace::backtrace_to_stacktrace(&context.backtrace);
::backtrace::clear_symbol_cache();
vars.insert(
error.code(),
Value::Object(
context
.vars
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
),
);
exceptions.push(exc);
}
}
impl From<ErrorContext> for Event<'_> {
fn from(error_context: ErrorContext) -> Self {
let mut sentry_event = Event {
message: Some(format!(
"{}: {}",
error_context.error().code(),
error_context.error().message()
)),
..Event::default()
};
let mut vars = BTreeMap::new();
error_context.populate_exception(&mut sentry_event.exception.values, &mut vars);
sentry_event
.extra
.insert("context_vars".into(), Value::Object(Map::from_iter(vars)));
sentry_event
}
}

30
src/fs.rs Normal file
View File

@ -0,0 +1,30 @@
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)
}

3
src/gb_repository.rs Normal file
View File

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

View File

@ -0,0 +1,967 @@
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),
}

42
src/git.rs Normal file
View File

@ -0,0 +1,42 @@
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::*;

17
src/git/blob.rs Normal file
View File

@ -0,0 +1,17 @@
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()
}
}

53
src/git/branch.rs Normal file
View File

@ -0,0 +1,53 @@
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()
}
}

75
src/git/commit.rs Normal file
View File

@ -0,0 +1,75 @@
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()
}
}

68
src/git/config.rs Normal file
View File

@ -0,0 +1,68 @@
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()),
}
}
}

392
src/git/credentials.rs Normal file
View File

@ -0,0 +1,392 @@
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)
}
}

421
src/git/diff.rs Normal file
View File

@ -0,0 +1,421 @@
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
}

62
src/git/error.rs Normal file
View File

@ -0,0 +1,62 @@
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>;

164
src/git/index.rs Normal file
View File

@ -0,0 +1,164 @@
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,
}
}
}

61
src/git/oid.rs Normal file
View File

@ -0,0 +1,61 @@
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
}
}

64
src/git/reference.rs Normal file
View File

@ -0,0 +1,64 @@
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

@ -0,0 +1,137 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,94 @@
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

@ -0,0 +1,93 @@
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

@ -0,0 +1,65 @@
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()))
}
}
}

43
src/git/remote.rs Normal file
View File

@ -0,0 +1,43 @@
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)
}
}

535
src/git/repository.rs Normal file
View File

@ -0,0 +1,535 @@
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)
}
}

22
src/git/show.rs Normal file
View File

@ -0,0 +1,22 @@
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()),
}
}

67
src/git/signature.rs Normal file
View File

@ -0,0 +1,67 @@
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()
}
}

147
src/git/tree.rs Normal file
View File

@ -0,0 +1,147 @@
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())
}
}
}

91
src/git/url.rs Normal file
View File

@ -0,0 +1,91 @@
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())
}
}

128
src/git/url/convert.rs Normal file
View File

@ -0,0 +1,128 @@
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);
}
}
}

147
src/git/url/parse.rs Normal file
View File

@ -0,0 +1,147 @@
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)
}

54
src/git/url/scheme.rs Normal file
View File

@ -0,0 +1,54 @@
/// 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())
}
}

118
src/id.rs Normal file
View File

@ -0,0 +1,118 @@
//! 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)
}
}

6
src/keys.rs Normal file
View File

@ -0,0 +1,6 @@
mod controller;
mod key;
pub mod storage;
pub use controller::*;
pub use key::{PrivateKey, PublicKey, SignError};

34
src/keys/controller.rs Normal file
View File

@ -0,0 +1,34 @@
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),
}

127
src/keys/key.rs Normal file
View File

@ -0,0 +1,127 @@
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)
}
}

43
src/keys/storage.rs Normal file
View File

@ -0,0 +1,43 @@
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(())
}
}

41
src/lib.rs Normal file
View File

@ -0,0 +1,41 @@
#![feature(error_generic_member_access)]
#![cfg_attr(windows, feature(windows_by_handle))]
#![cfg_attr(
all(windows, not(test), not(debug_assertions)),
windows_subsystem = "windows"
)]
// FIXME(qix-): Stuff we want to fix but don't have a lot of time for.
// FIXME(qix-): PRs welcome!
#![allow(
clippy::used_underscore_binding,
clippy::module_name_repetitions,
clippy::struct_field_names,
clippy::too_many_lines
)]
pub mod askpass;
pub mod assets;
pub mod database;
pub mod dedup;
pub mod deltas;
pub mod error;
pub mod fs;
pub mod gb_repository;
pub mod git;
pub mod id;
pub mod keys;
pub mod lock;
pub mod path;
pub mod project_repository;
pub mod projects;
pub mod reader;
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;

51
src/lock.rs Normal file
View File

@ -0,0 +1,51 @@
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)
}
}

48
src/path.rs Normal file
View File

@ -0,0 +1,48 @@
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

@ -0,0 +1,8 @@
mod config;
pub mod conflicts;
mod repository;
pub use config::Config;
pub use repository::{LogUntil, OpenError, RemoteError, Repository};
pub mod signatures;

View File

@ -0,0 +1,51 @@
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

@ -0,0 +1,144 @@
// 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

@ -0,0 +1,698 @@
use std::{
path,
str::FromStr,
sync::{atomic::AtomicUsize, Arc},
};
use anyhow::{Context, Result};
use crate::{
askpass,
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, askpass::Context::Push { 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, askpass::Context::Fetch { action })
.await
} else {
tracing::warn!("received askpass fetch prompt but no broker was supplied; returning None");
None
}
}

View File

@ -0,0 +1,22 @@
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))
}

9
src/projects.rs Normal file
View File

@ -0,0 +1,9 @@
pub mod controller;
mod project;
pub mod storage;
pub use controller::*;
pub use project::{AuthKey, CodePushState, FetchResult, Project, ProjectId};
pub use storage::UpdateRequest;
pub use project::ApiProject;

344
src/projects/controller.rs Normal file
View File

@ -0,0 +1,344 @@
use super::{storage, storage::UpdateRequest, Project, ProjectId};
use crate::{gb_repository, project_repository, users};
use anyhow::Context;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[async_trait]
pub trait Watchers {
fn watch(&self, project: &Project) -> anyhow::Result<()>;
async fn stop(&self, id: ProjectId) -> anyhow::Result<()>;
async fn fetch(&self, id: ProjectId) -> anyhow::Result<()>;
async fn push(&self, id: ProjectId) -> anyhow::Result<()>;
}
#[derive(Clone)]
pub struct Controller {
local_data_dir: PathBuf,
projects_storage: storage::Storage,
users: users::Controller,
watchers: Option<Arc<dyn Watchers + Send + Sync>>,
}
impl Controller {
pub fn new(
local_data_dir: PathBuf,
projects_storage: storage::Storage,
users: users::Controller,
watchers: Option<impl Watchers + Send + Sync + 'static>,
) -> Self {
Self {
local_data_dir,
projects_storage,
users,
watchers: watchers.map(|w| Arc::new(w) as Arc<_>),
}
}
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.fetch(project.id).await {
tracing::error!(
project_id = %project.id,
?error,
"failed to post fetch project event"
);
}
}
if let Err(error) = watchers.push(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),
}

112
src/projects/project.rs Normal file
View File

@ -0,0 +1,112 @@
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")
}
}

162
src/projects/storage.rs Normal file
View File

@ -0,0 +1,162 @@
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(())
}
}

443
src/reader.rs Normal file
View File

@ -0,0 +1,443 @@
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)
}
}

14
src/sessions.rs Normal file
View File

@ -0,0 +1,14 @@
mod controller;
mod iterator;
mod reader;
pub mod session;
mod writer;
pub mod database;
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;

View File

@ -0,0 +1,91 @@
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)
}
}

182
src/sessions/database.rs Normal file
View File

@ -0,0 +1,182 @@
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
",
)?)
}

68
src/sessions/iterator.rs Normal file
View File

@ -0,0 +1,68 @@
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,
}
}
}

105
src/sessions/reader.rs Normal file
View File

@ -0,0 +1,105 @@
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))
}
}

126
src/sessions/session.rs Normal file
View File

@ -0,0 +1,126 @@
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,
},
})
}
}

108
src/sessions/writer.rs Normal file
View File

@ -0,0 +1,108 @@
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(())
}
}

67
src/ssh.rs Normal file
View File

@ -0,0 +1,67 @@
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(())
}
}
}

73
src/storage.rs Normal file
View File

@ -0,0 +1,73 @@
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
#[cfg(target_family = "unix")]
use std::os::unix::prelude::*;
#[derive(Debug, Default, Clone)]
pub struct Storage {
local_data_dir: Arc<RwLock<PathBuf>>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
IO(#[from] std::io::Error),
}
impl Storage {
pub fn new<P: AsRef<Path>>(local_data_dir: P) -> Storage {
Storage {
local_data_dir: Arc::new(RwLock::new(local_data_dir.as_ref().to_path_buf())),
}
}
pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Option<String>, Error> {
let local_data_dir = self.local_data_dir.read().unwrap();
let file_path = local_data_dir.join(path);
if !file_path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&file_path).map_err(Error::IO)?;
Ok(Some(contents))
}
pub fn write<P: AsRef<Path>>(&self, path: P, content: &str) -> Result<(), Error> {
let local_data_dir = self.local_data_dir.write().unwrap();
let file_path = local_data_dir.join(path);
let dir = file_path.parent().unwrap();
if !dir.exists() {
fs::create_dir_all(dir).map_err(Error::IO)?;
}
fs::write(&file_path, content).map_err(Error::IO)?;
// Set the permissions to be user-only. We can't actually
// do this on Windows, so we ignore that platform.
#[cfg(target_family = "unix")]
{
let metadata = fs::metadata(file_path.clone())?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600); // User read/write
fs::set_permissions(file_path.clone(), permissions)?;
}
Ok(())
}
pub fn delete<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
let local_data_dir = self.local_data_dir.write().unwrap();
let file_path = local_data_dir.join(path);
if !file_path.exists() {
return Ok(());
}
if file_path.is_dir() {
fs::remove_dir_all(file_path.clone()).map_err(Error::IO)?;
} else {
fs::remove_file(file_path.clone()).map_err(Error::IO)?;
}
Ok(())
}
}

1
src/types.rs Normal file
View File

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

90
src/types/default_true.rs Normal file
View File

@ -0,0 +1,90 @@
#[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
}
}

6
src/users.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod controller;
pub mod storage;
mod user;
pub use controller::*;
pub use user::User;

57
src/users/controller.rs Normal file
View File

@ -0,0 +1,57 @@
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),
}

46
src/users/storage.rs Normal file
View File

@ -0,0 +1,46 @@
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(())
}
}

35
src/users/user.rs Normal file
View File

@ -0,0 +1,35 @@
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)
}
}
}

29
src/virtual_branches.rs Normal file
View File

@ -0,0 +1,29 @@
pub mod branch;
pub use branch::{Branch, BranchId};
pub mod context;
pub mod target;
pub mod errors;
mod files;
pub use files::*;
pub mod integration;
pub use integration::GITBUTLER_INTEGRATION_REFERENCE;
mod base;
pub use base::*;
pub mod controller;
pub use controller::Controller;
mod iterator;
pub use iterator::BranchIterator as Iterator;
mod r#virtual;
pub use r#virtual::*;
mod remote;
pub use remote::*;
mod state;

View File

@ -0,0 +1,657 @@
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)
}

View File

@ -0,0 +1,237 @@
mod file_ownership;
mod hunk;
mod ownership;
mod reader;
mod writer;
pub use file_ownership::OwnershipClaim;
pub use hunk::Hunk;
pub use ownership::reconcile_claims;
pub use ownership::BranchOwnershipClaims;
pub use reader::BranchReader as Reader;
pub use writer::BranchWriter as Writer;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use crate::{git, id::Id};
pub type BranchId = Id<Branch>;
// this is the struct for the virtual branch data that is stored in our data
// store. it is more or less equivalent to a git branch reference, but it is not
// stored or accessible from the git repository itself. it is stored in our
// session storage under the branches/ directory.
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
pub struct Branch {
pub id: BranchId,
pub name: String,
pub notes: String,
pub applied: bool,
pub upstream: Option<git::RemoteRefname>,
// upstream_head is the last commit on we've pushed to the upstream branch
pub upstream_head: Option<git::Oid>,
#[serde(
serialize_with = "serialize_u128",
deserialize_with = "deserialize_u128"
)]
pub created_timestamp_ms: u128,
#[serde(
serialize_with = "serialize_u128",
deserialize_with = "deserialize_u128"
)]
pub updated_timestamp_ms: u128,
/// tree is the last git tree written to a session, or merge base tree if this is new. use this for delta calculation from the session data
pub tree: git::Oid,
/// head is id of the last "virtual" commit in this branch
pub head: git::Oid,
pub ownership: BranchOwnershipClaims,
// order is the number by which UI should sort branches
pub order: usize,
// is Some(timestamp), the branch is considered a default destination for new changes.
// if more than one branch is selected, the branch with the highest timestamp wins.
pub selected_for_changes: Option<i64>,
}
fn serialize_u128<S>(x: &u128, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_str(&x.to_string())
}
fn deserialize_u128<'de, D>(d: D) -> Result<u128, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
let x: u128 = s.parse().map_err(serde::de::Error::custom)?;
Ok(x)
}
impl Branch {
pub fn refname(&self) -> git::VirtualRefname {
self.into()
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct BranchUpdateRequest {
pub id: BranchId,
pub name: Option<String>,
pub notes: Option<String>,
pub ownership: Option<BranchOwnershipClaims>,
pub order: Option<usize>,
pub upstream: Option<String>, // just the branch name, so not refs/remotes/origin/branchA, just branchA
pub selected_for_changes: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct BranchCreateRequest {
pub name: Option<String>,
pub ownership: Option<BranchOwnershipClaims>,
pub order: Option<usize>,
pub selected_for_changes: Option<bool>,
}
impl Branch {
pub fn from_reader(reader: &crate::reader::Reader<'_>) -> Result<Self, crate::reader::Error> {
let results = reader.batch(&[
"id",
"meta/name",
"meta/notes",
"meta/applied",
"meta/order",
"meta/upstream",
"meta/upstream_head",
"meta/tree",
"meta/head",
"meta/created_timestamp_ms",
"meta/updated_timestamp_ms",
"meta/ownership",
"meta/selected_for_changes",
])?;
let id: String = results[0].clone()?.try_into()?;
let id: BranchId = id.parse().map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(std::io::ErrorKind::Other, format!("id: {}", e)).into(),
)
})?;
let name: String = results[1].clone()?.try_into()?;
let notes: String = match results[2].clone() {
Ok(notes) => Ok(notes.try_into()?),
Err(crate::reader::Error::NotFound) => Ok(String::new()),
Err(e) => Err(e),
}?;
let applied = match results[3].clone() {
Ok(applied) => applied.try_into(),
_ => Ok(false),
}
.unwrap_or(false);
let order: usize = match results[4].clone() {
Ok(order) => Ok(order.try_into()?),
Err(crate::reader::Error::NotFound) => Ok(0),
Err(e) => Err(e),
}?;
let upstream = match results[5].clone() {
Ok(crate::reader::Content::UTF8(upstream)) => {
if upstream.is_empty() {
Ok(None)
} else {
upstream
.parse::<git::RemoteRefname>()
.map(Some)
.map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("meta/upstream: {}", e),
)
.into(),
)
})
}
}
Ok(_) | Err(crate::reader::Error::NotFound) => Ok(None),
Err(e) => Err(e),
}?;
let upstream_head = match results[6].clone() {
Ok(crate::reader::Content::UTF8(upstream_head)) => {
upstream_head.parse().map(Some).map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("meta/upstream_head: {}", e),
)
.into(),
)
})
}
Ok(_) | Err(crate::reader::Error::NotFound) => Ok(None),
Err(e) => Err(e),
}?;
let tree: String = results[7].clone()?.try_into()?;
let head: String = results[8].clone()?.try_into()?;
let created_timestamp_ms = results[9].clone()?.try_into()?;
let updated_timestamp_ms = results[10].clone()?.try_into()?;
let ownership_string: String = results[11].clone()?.try_into()?;
let ownership = ownership_string.parse().map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(std::io::ErrorKind::Other, format!("meta/ownership: {}", e))
.into(),
)
})?;
let selected_for_changes = match results[12].clone() {
Ok(raw_ts) => {
let ts = raw_ts.try_into().map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("meta/selected_for_changes: {}", e),
)
.into(),
)
})?;
Ok(Some(ts))
}
Err(crate::reader::Error::NotFound) => Ok(None),
Err(e) => Err(e),
}?;
Ok(Self {
id,
name,
notes,
applied,
upstream,
upstream_head,
tree: tree.parse().map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(std::io::ErrorKind::Other, format!("meta/tree: {}", e))
.into(),
)
})?,
head: head.parse().map_err(|e| {
crate::reader::Error::Io(
std::io::Error::new(std::io::ErrorKind::Other, format!("meta/head: {}", e))
.into(),
)
})?,
created_timestamp_ms,
updated_timestamp_ms,
ownership,
order,
selected_for_changes,
})
}
}

View File

@ -0,0 +1,178 @@
use std::{fmt, path, str::FromStr, vec};
use anyhow::{Context, Result};
use super::hunk::Hunk;
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct OwnershipClaim {
pub file_path: path::PathBuf,
pub hunks: Vec<Hunk>,
}
impl FromStr for OwnershipClaim {
type Err = anyhow::Error;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
let mut file_path_parts = vec![];
let mut ranges = vec![];
for part in value.split(':').rev() {
match part
.split(',')
.map(str::parse)
.collect::<Result<Vec<Hunk>>>()
{
Ok(rr) => ranges.extend(rr),
Err(_) => {
file_path_parts.insert(0, part);
}
}
}
if ranges.is_empty() {
Err(anyhow::anyhow!("ownership ranges cannot be empty"))
} else {
Ok(Self {
file_path: file_path_parts
.join(":")
.parse()
.context(format!("failed to parse file path from {}", value))?,
hunks: ranges.clone(),
})
}
}
}
impl OwnershipClaim {
pub fn is_full(&self) -> bool {
self.hunks.is_empty()
}
pub fn contains(&self, another: &OwnershipClaim) -> bool {
if !self.file_path.eq(&another.file_path) {
return false;
}
if self.hunks.is_empty() {
// full ownership contains any partial ownership
return true;
}
if another.hunks.is_empty() {
// partial ownership contains no full ownership
return false;
}
another.hunks.iter().all(|hunk| self.hunks.contains(hunk))
}
// return a copy of self, with another ranges added
pub fn plus(&self, another: &OwnershipClaim) -> OwnershipClaim {
if !self.file_path.eq(&another.file_path) {
return self.clone();
}
if self.hunks.is_empty() {
// full ownership + partial ownership = full ownership
return self.clone();
}
if another.hunks.is_empty() {
// partial ownership + full ownership = full ownership
return another.clone();
}
let mut hunks = self
.hunks
.iter()
.filter(|hunk| !another.hunks.contains(hunk))
.cloned()
.collect::<Vec<Hunk>>();
another.hunks.iter().for_each(|hunk| {
hunks.insert(0, hunk.clone());
});
OwnershipClaim {
file_path: self.file_path.clone(),
hunks,
}
}
// returns (taken, remaining)
// if all of the ranges are removed, return None
pub fn minus(
&self,
another: &OwnershipClaim,
) -> (Option<OwnershipClaim>, Option<OwnershipClaim>) {
if !self.file_path.eq(&another.file_path) {
// no changes
return (None, Some(self.clone()));
}
if another.hunks.is_empty() {
// any ownership - full ownership = empty ownership
return (Some(self.clone()), None);
}
if self.hunks.is_empty() {
// full ownership - partial ownership = full ownership, since we don't know all the
// hunks.
return (None, Some(self.clone()));
}
let mut left = self.hunks.clone();
let mut taken = vec![];
for range in &another.hunks {
left = left
.iter()
.flat_map(|r: &Hunk| -> Vec<Hunk> {
if r.eq(range) {
taken.push(r.clone());
vec![]
} else {
vec![r.clone()]
}
})
.collect();
}
(
if taken.is_empty() {
None
} else {
Some(OwnershipClaim {
file_path: self.file_path.clone(),
hunks: taken,
})
},
if left.is_empty() {
None
} else {
Some(OwnershipClaim {
file_path: self.file_path.clone(),
hunks: left,
})
},
)
}
}
impl fmt::Display for OwnershipClaim {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
if self.hunks.is_empty() {
write!(f, "{}", self.file_path.display())
} else {
write!(
f,
"{}:{}",
self.file_path.display(),
self.hunks
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(",")
)
}
}
}

View File

@ -0,0 +1,169 @@
use std::{fmt::Display, ops::RangeInclusive, str::FromStr};
use anyhow::{anyhow, Context, Result};
use crate::git::diff;
#[derive(Debug, Eq, Clone)]
pub struct Hunk {
pub hash: Option<String>,
pub timestamp_ms: Option<u128>,
pub start: u32,
pub end: u32,
}
impl From<&diff::GitHunk> for Hunk {
fn from(hunk: &diff::GitHunk) -> Self {
Hunk {
start: hunk.new_start,
end: hunk.new_start + hunk.new_lines,
hash: Some(Hunk::hash(&hunk.diff)),
timestamp_ms: None,
}
}
}
impl PartialEq for Hunk {
fn eq(&self, other: &Self) -> bool {
if self.hash.is_some() && other.hash.is_some() {
self.hash == other.hash && self.start == other.start && self.end == other.end
} else {
self.start == other.start && self.end == other.end
}
}
}
impl From<RangeInclusive<u32>> for Hunk {
fn from(range: RangeInclusive<u32>) -> Self {
Hunk {
start: *range.start(),
end: *range.end(),
hash: None,
timestamp_ms: None,
}
}
}
impl FromStr for Hunk {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut range = s.split('-');
let start = if let Some(raw_start) = range.next() {
raw_start
.parse::<u32>()
.context(format!("failed to parse start of range: {}", s))
} else {
Err(anyhow!("invalid range: {}", s))
}?;
let end = if let Some(raw_end) = range.next() {
raw_end
.parse::<u32>()
.context(format!("failed to parse end of range: {}", s))
} else {
Err(anyhow!("invalid range: {}", s))
}?;
let hash = if let Some(raw_hash) = range.next() {
if raw_hash.is_empty() {
None
} else {
Some(raw_hash.to_string())
}
} else {
None
};
let timestamp_ms = if let Some(raw_timestamp_ms) = range.next() {
Some(
raw_timestamp_ms
.parse::<u128>()
.context(format!("failed to parse timestamp_ms of range: {}", s))?,
)
} else {
None
};
Hunk::new(start, end, hash, timestamp_ms)
}
}
impl Display for Hunk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.start, self.end)?;
match (self.hash.as_ref(), self.timestamp_ms.as_ref()) {
(Some(hash), Some(timestamp_ms)) => write!(f, "-{}-{}", hash, timestamp_ms),
(Some(hash), None) => write!(f, "-{}", hash),
(None, Some(timestamp_ms)) => write!(f, "--{}", timestamp_ms),
(None, None) => Ok(()),
}
}
}
impl Hunk {
pub fn new(
start: u32,
end: u32,
hash: Option<String>,
timestamp_ms: Option<u128>,
) -> Result<Self> {
if start > end {
Err(anyhow!("invalid range: {}-{}", start, end))
} else {
Ok(Hunk {
hash,
timestamp_ms,
start,
end,
})
}
}
pub fn with_hash(&self, hash: &str) -> Self {
Hunk {
start: self.start,
end: self.end,
hash: Some(hash.to_string()),
timestamp_ms: self.timestamp_ms,
}
}
pub fn with_timestamp(&self, timestamp_ms: u128) -> Self {
Hunk {
start: self.start,
end: self.end,
hash: self.hash.clone(),
timestamp_ms: Some(timestamp_ms),
}
}
pub fn timestam_ms(&self) -> Option<u128> {
self.timestamp_ms
}
pub fn contains(&self, line: u32) -> bool {
self.start <= line && self.end >= line
}
pub fn intersects(&self, another: &diff::GitHunk) -> bool {
self.contains(another.new_start)
|| self.contains(another.new_start + another.new_lines)
|| another.contains(self.start)
|| another.contains(self.end)
}
pub fn shallow_eq(&self, other: &diff::GitHunk) -> bool {
self.start == other.new_start && self.end == other.new_start + other.new_lines
}
pub fn hash(diff: &str) -> String {
let addition = diff
.lines()
.skip(1) // skip the first line which is the diff header
.filter(|line| line.starts_with('+') || line.starts_with('-')) // exclude context lines
.collect::<Vec<_>>()
.join("\n");
format!("{:x}", md5::compute(addition))
}
}

View File

@ -0,0 +1,183 @@
use std::{collections::HashSet, fmt, str::FromStr};
use itertools::Itertools;
use serde::{Deserialize, Serialize, Serializer};
use super::{Branch, OwnershipClaim};
use anyhow::Result;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BranchOwnershipClaims {
pub claims: Vec<OwnershipClaim>,
}
impl Serialize for BranchOwnershipClaims {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.to_string().as_str())
}
}
impl<'de> Deserialize<'de> for BranchOwnershipClaims {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl fmt::Display for BranchOwnershipClaims {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for file in &self.claims {
writeln!(f, "{}", file)?;
}
Ok(())
}
}
impl FromStr for BranchOwnershipClaims {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut ownership = BranchOwnershipClaims::default();
for line in s.lines() {
ownership.claims.push(line.parse()?);
}
Ok(ownership)
}
}
impl BranchOwnershipClaims {
pub fn is_empty(&self) -> bool {
self.claims.is_empty()
}
pub fn contains(&self, another: &BranchOwnershipClaims) -> bool {
if another.is_empty() {
return true;
}
if self.is_empty() {
return false;
}
for file_ownership in &another.claims {
let mut found = false;
for self_file_ownership in &self.claims {
if self_file_ownership.file_path == file_ownership.file_path
&& self_file_ownership.contains(file_ownership)
{
found = true;
break;
}
}
if !found {
return false;
}
}
true
}
pub fn put(&mut self, ownership: &OwnershipClaim) {
let target = self
.claims
.iter()
.filter(|o| !o.is_full()) // only consider explicit ownership
.find(|o| o.file_path == ownership.file_path)
.cloned();
self.claims
.retain(|o| o.is_full() || o.file_path != ownership.file_path);
if let Some(target) = target {
self.claims.insert(0, target.plus(ownership));
} else {
self.claims.insert(0, ownership.clone());
}
}
// modifies the ownership in-place and returns the file ownership that was taken, if any.
pub fn take(&mut self, ownership: &OwnershipClaim) -> Vec<OwnershipClaim> {
let mut taken = Vec::new();
let mut remaining = Vec::new();
for file_ownership in &self.claims {
if file_ownership.file_path == ownership.file_path {
let (taken_ownership, remaining_ownership) = file_ownership.minus(ownership);
if let Some(taken_ownership) = taken_ownership {
taken.push(taken_ownership);
}
if let Some(remaining_ownership) = remaining_ownership {
remaining.push(remaining_ownership);
}
} else {
remaining.push(file_ownership.clone());
}
}
self.claims = remaining;
taken
}
}
#[derive(Debug, Clone)]
pub struct ClaimOutcome {
pub updated_branch: Branch,
pub removed_claims: Vec<OwnershipClaim>,
}
pub fn reconcile_claims(
all_branches: Vec<Branch>,
claiming_branch: &Branch,
new_claims: &[OwnershipClaim],
) -> Result<Vec<ClaimOutcome>> {
let mut other_branches = all_branches
.into_iter()
.filter(|branch| branch.applied)
.filter(|branch| branch.id != claiming_branch.id)
.collect::<Vec<_>>();
let mut claim_outcomes: Vec<ClaimOutcome> = Vec::new();
for branch in &mut other_branches {
let taken = new_claims
.iter()
.flat_map(|c| branch.ownership.take(c))
.collect_vec();
claim_outcomes.push(ClaimOutcome {
updated_branch: branch.clone(),
removed_claims: taken,
});
}
// Add the claiming branch to the list of outcomes
claim_outcomes.push(ClaimOutcome {
updated_branch: Branch {
ownership: BranchOwnershipClaims {
claims: new_claims.to_owned(),
},
..claiming_branch.clone()
},
removed_claims: Vec::new(),
});
// Check the outcomes consistency and error out if they would result in a hunk being claimed by multiple branches
let mut seen = HashSet::new();
for outcome in claim_outcomes.clone() {
for claim in outcome.updated_branch.ownership.claims {
for hunk in claim.hunks {
if !seen.insert(format!(
"{}-{}-{}",
claim.file_path.to_str().unwrap_or_default(),
hunk.start,
hunk.end
)) {
return Err(anyhow::anyhow!("inconsistent ownership claims"));
}
}
}
}
Ok(claim_outcomes)
}

View File

@ -0,0 +1,19 @@
use crate::{reader, sessions};
use super::{Branch, BranchId};
pub struct BranchReader<'r> {
reader: &'r reader::Reader<'r>,
}
impl<'r> BranchReader<'r> {
pub fn new(reader: &'r sessions::Reader<'r>) -> Self {
Self {
reader: reader.reader(),
}
}
pub fn read(&self, id: &BranchId) -> Result<Branch, reader::Error> {
Branch::from_reader(&self.reader.sub(format!("branches/{}", id)))
}
}

View File

@ -0,0 +1,160 @@
use std::path;
use anyhow::Result;
use crate::{gb_repository, reader, virtual_branches::state::VirtualBranchesHandle, writer};
use super::Branch;
pub struct BranchWriter<'writer> {
repository: &'writer gb_repository::Repository,
writer: writer::DirWriter,
reader: reader::Reader<'writer>,
state_handle: VirtualBranchesHandle,
}
impl<'writer> BranchWriter<'writer> {
pub fn new<P: AsRef<path::Path>>(
repository: &'writer gb_repository::Repository,
path: P,
) -> Result<Self, std::io::Error> {
let reader = reader::Reader::open(repository.root())?;
let writer = writer::DirWriter::open(repository.root())?;
let state_handle = VirtualBranchesHandle::new(path.as_ref());
Ok(Self {
repository,
writer,
reader,
state_handle,
})
}
pub fn delete(&self, branch: &Branch) -> Result<()> {
match self
.reader
.sub(format!("branches/{}", branch.id))
.read("id")
{
Ok(_) => {
self.repository.mark_active_session()?;
let _lock = self.repository.lock();
self.writer.remove(format!("branches/{}", branch.id))?;
// Write in the state file as well
let _ = self.state_handle.remove_branch(branch.id);
Ok(())
}
Err(reader::Error::NotFound) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub fn write(&self, branch: &mut Branch) -> Result<()> {
let reader = self.reader.sub(format!("branches/{}", branch.id));
match Branch::from_reader(&reader) {
Ok(existing) if existing.eq(branch) => return Ok(()),
Ok(_) | Err(reader::Error::NotFound) => {}
Err(err) => return Err(err.into()),
}
self.repository.mark_active_session()?;
branch.updated_timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis();
let mut batch = vec![];
batch.push(writer::BatchTask::Write(
format!("branches/{}/id", branch.id),
branch.id.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/name", branch.id),
branch.name.clone(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/notes", branch.id),
branch.notes.clone(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/order", branch.id),
branch.order.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/applied", branch.id),
branch.applied.to_string(),
));
if let Some(upstream) = &branch.upstream {
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/upstream", branch.id),
upstream.to_string(),
));
} else {
batch.push(writer::BatchTask::Remove(format!(
"branches/{}/meta/upstream",
branch.id
)));
}
if let Some(upstream_head) = &branch.upstream_head {
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/upstream_head", branch.id),
upstream_head.to_string(),
));
} else {
batch.push(writer::BatchTask::Remove(format!(
"branches/{}/meta/upstream_head",
branch.id
)));
}
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/tree", branch.id),
branch.tree.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/head", branch.id),
branch.head.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/created_timestamp_ms", branch.id),
branch.created_timestamp_ms.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/updated_timestamp_ms", branch.id),
branch.updated_timestamp_ms.to_string(),
));
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/ownership", branch.id),
branch.ownership.to_string(),
));
if let Some(selected_for_changes) = branch.selected_for_changes {
batch.push(writer::BatchTask::Write(
format!("branches/{}/meta/selected_for_changes", branch.id),
selected_for_changes.to_string(),
));
} else {
batch.push(writer::BatchTask::Remove(format!(
"branches/{}/meta/selected_for_changes",
branch.id
)));
}
self.writer.batch(&batch)?;
// Write in the state file as well
self.state_handle.set_branch(branch.clone())?;
Ok(())
}
}

View File

@ -0,0 +1,124 @@
use crate::git::diff;
pub fn hunk_with_context(
hunk_diff: &str,
hunk_old_start_line: usize,
hunk_new_start_line: usize,
is_binary: bool,
context_lines: usize,
file_lines_before: &[&str],
change_type: diff::ChangeType,
) -> diff::GitHunk {
let diff_lines = hunk_diff
.lines()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
if diff_lines.is_empty() {
#[allow(clippy::cast_possible_truncation)]
return diff::GitHunk {
diff: hunk_diff.to_owned(),
old_start: hunk_old_start_line as u32,
old_lines: 0,
new_start: hunk_new_start_line as u32,
new_lines: 0,
binary: is_binary,
change_type,
};
}
let new_file = hunk_old_start_line == 0;
let deleted_file = hunk_new_start_line == 0;
let removed_count = diff_lines
.iter()
.filter(|line| line.starts_with('-'))
.count();
let added_count = diff_lines
.iter()
.filter(|line| line.starts_with('+'))
.count();
// Get context lines before the diff
let mut context_before = Vec::new();
let before_context_ending_index = if removed_count == 0 {
// Compensate for when the removed_count is 0
hunk_old_start_line
} else {
hunk_old_start_line.saturating_sub(1)
};
let before_context_starting_index = before_context_ending_index.saturating_sub(context_lines);
for index in before_context_starting_index..before_context_ending_index {
if let Some(l) = file_lines_before.get(index) {
let mut s = (*l).to_string();
s.insert(0, ' ');
context_before.push(s);
}
}
// Get context lines after the diff
let mut context_after = Vec::new();
let after_context_starting_index = before_context_ending_index + removed_count;
let after_context_ending_index = after_context_starting_index + context_lines;
for index in after_context_starting_index..after_context_ending_index {
if let Some(l) = file_lines_before.get(index) {
let mut s = (*l).to_string();
s.insert(0, ' ');
context_after.push(s);
}
}
let start_line_before = if new_file {
// If we've created a new file, start_line_before should be 0
0
} else {
before_context_starting_index + 1
};
let start_line_after = if deleted_file {
// If we've deleted a new file, start_line_after should be 0
0
} else if added_count == 0 {
// Compensate for when the added_count is 0
hunk_new_start_line.saturating_sub(context_before.len()) + 1
} else {
hunk_new_start_line.saturating_sub(context_before.len())
};
let line_count_before = removed_count + context_before.len() + context_after.len();
let line_count_after = added_count + context_before.len() + context_after.len();
let header = format!(
"@@ -{},{} +{},{} @@",
start_line_before, line_count_before, start_line_after, line_count_after
);
let body = &diff_lines[1..];
// Update unidiff body with context lines
let mut b = Vec::new();
b.extend(context_before.clone());
b.extend_from_slice(body);
b.extend(context_after.clone());
let body = b;
// Construct a new diff with updated header and body
let mut diff_lines = Vec::new();
diff_lines.push(header);
diff_lines.extend(body);
let mut diff = diff_lines.join("\n");
// Add trailing newline
diff.push('\n');
#[allow(clippy::cast_possible_truncation)]
let hunk = diff::GitHunk {
diff,
old_start: start_line_before as u32,
old_lines: line_count_before as u32,
new_start: start_line_after as u32,
new_lines: line_count_after as u32,
binary: is_binary,
change_type,
};
hunk
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,837 @@
use crate::{
error::Error,
git,
project_repository::{self, RemoteError},
projects::ProjectId,
};
use super::{branch::BranchOwnershipClaims, BranchId, GITBUTLER_INTEGRATION_REFERENCE};
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("head is detached")]
DetachedHead,
#[error("head is {0}")]
InvalidHead(String),
#[error("integration commit not found")]
NoIntegrationCommit,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<VerifyError> for crate::error::Error {
fn from(value: VerifyError) -> Self {
match value {
VerifyError::DetachedHead => crate::error::Error::UserError {
code: crate::error::Code::ProjectHead,
message: format!(
"Project in detached head state. Please checkout {0} to continue.",
GITBUTLER_INTEGRATION_REFERENCE.branch()
),
},
VerifyError::InvalidHead(head) => crate::error::Error::UserError {
code: crate::error::Code::ProjectHead,
message: format!(
"Project is on {}. Please checkout {} to continue.",
head,
GITBUTLER_INTEGRATION_REFERENCE.branch()
),
},
VerifyError::NoIntegrationCommit => crate::error::Error::UserError {
code: crate::error::Code::ProjectHead,
message: "GibButler's integration commit not found on head.".to_string(),
},
VerifyError::Other(error) => {
tracing::error!(?error);
crate::error::Error::Unknown
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum DeleteBranchError {
#[error(transparent)]
UnapplyBranch(#[from] UnapplyBranchError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum ResetBranchError {
#[error("commit {0} not in the branch")]
CommitNotFoundInBranch(git::Oid),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum ApplyBranchError {
#[error("project")]
Conflict(ProjectConflictError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("branch conflicts with other branches - sorry bro.")]
BranchConflicts(BranchId),
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum UnapplyOwnershipError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("project is in conflict state")]
Conflict(ProjectConflictError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum UnapplyBranchError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum FlushAppliedVbranchesError {
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum ListVirtualBranchesError {
#[error("project")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum CreateVirtualBranchError {
#[error("project")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum MergeVirtualBranchUpstreamError {
#[error("project")]
Conflict(ProjectConflictError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum CommitError {
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("will not commit conflicted files")]
Conflicted(ProjectConflictError),
#[error("commit hook rejected")]
CommitHookRejected(String),
#[error("commit msg hook rejected")]
CommitMsgHookRejected(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum PushError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error(transparent)]
Remote(#[from] project_repository::RemoteError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum IsRemoteBranchMergableError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(git::RemoteRefname),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum IsVirtualBranchMergeable {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug)]
pub struct ForcePushNotAllowedError {
pub project_id: ProjectId,
}
impl From<ForcePushNotAllowedError> for Error {
fn from(_value: ForcePushNotAllowedError) -> Self {
Error::UserError {
code: crate::error::Code::Branches,
message: "Action will lead to force pushing, which is not allowed for this".to_string(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AmendError {
#[error("force push not allowed")]
ForcePushNotAllowed(ForcePushNotAllowedError),
#[error("target ownership not found")]
TargetOwnerhshipNotFound(BranchOwnershipClaims),
#[error("branch has no commits")]
BranchHasNoCommits,
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("project is in conflict state")]
Conflict(ProjectConflictError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum CherryPickError {
#[error("target commit {0} not found ")]
CommitNotFound(git::Oid),
#[error("can not cherry pick not applied branch")]
NotApplied,
#[error("project is in conflict state")]
Conflict(ProjectConflictError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum SquashError {
#[error("force push not allowed")]
ForcePushNotAllowed(ForcePushNotAllowedError),
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("commit {0} not in the branch")]
CommitNotFound(git::Oid),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("project is in conflict state")]
Conflict(ProjectConflictError),
#[error("can not squash root commit")]
CantSquashRootCommit,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum FetchFromTargetError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("failed to fetch")]
Remote(RemoteError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<FetchFromTargetError> for Error {
fn from(value: FetchFromTargetError) -> Self {
match value {
FetchFromTargetError::DefaultTargetNotSet(error) => error.into(),
FetchFromTargetError::Remote(error) => error.into(),
FetchFromTargetError::Other(error) => {
tracing::error!(?error, "fetch from target error");
Error::Unknown
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum UpdateCommitMessageError {
#[error("force push not allowed")]
ForcePushNotAllowed(ForcePushNotAllowedError),
#[error("empty message")]
EmptyMessage,
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("commit {0} not in the branch")]
CommitNotFound(git::Oid),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("project is in conflict state")]
Conflict(ProjectConflictError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<UpdateCommitMessageError> for Error {
fn from(value: UpdateCommitMessageError) -> Self {
match value {
UpdateCommitMessageError::ForcePushNotAllowed(error) => error.into(),
UpdateCommitMessageError::EmptyMessage => Error::UserError {
message: "Commit message can not be empty".to_string(),
code: crate::error::Code::Branches,
},
UpdateCommitMessageError::DefaultTargetNotSet(error) => error.into(),
UpdateCommitMessageError::CommitNotFound(oid) => Error::UserError {
message: format!("Commit {} not found", oid),
code: crate::error::Code::Branches,
},
UpdateCommitMessageError::BranchNotFound(error) => error.into(),
UpdateCommitMessageError::Conflict(error) => error.into(),
UpdateCommitMessageError::Other(error) => {
tracing::error!(?error, "update commit message error");
Error::Unknown
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum GetBaseBranchDataError {
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum SetBaseBranchError {
#[error("wd is dirty")]
DirtyWorkingDirectory,
#[error("branch {0} not found")]
BranchNotFound(git::RemoteRefname),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum UpdateBaseBranchError {
#[error("project is in conflicting state")]
Conflict(ProjectConflictError),
#[error("no default target set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum MoveCommitError {
#[error("source branch contains hunks locked to the target commit")]
SourceLocked,
#[error("project is in conflicted state")]
Conflicted(ProjectConflictError),
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error("commit not found")]
CommitNotFound(git::Oid),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<MoveCommitError> for crate::error::Error {
fn from(value: MoveCommitError) -> Self {
match value {
MoveCommitError::SourceLocked => Error::UserError {
message: "Source branch contains hunks locked to the target commit".to_string(),
code: crate::error::Code::Branches,
},
MoveCommitError::Conflicted(error) => error.into(),
MoveCommitError::DefaultTargetNotSet(error) => error.into(),
MoveCommitError::BranchNotFound(error) => error.into(),
MoveCommitError::CommitNotFound(oid) => Error::UserError {
message: format!("Commit {} not found", oid),
code: crate::error::Code::Branches,
},
MoveCommitError::Other(error) => {
tracing::error!(?error, "move commit to vbranch error");
Error::Unknown
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CreateVirtualBranchFromBranchError {
#[error("failed to apply")]
ApplyBranch(ApplyBranchError),
#[error("can't make branch from default target")]
CantMakeBranchFromDefaultTarget,
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("{0} not found")]
BranchNotFound(git::Refname),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug)]
pub struct ProjectConflictError {
pub project_id: ProjectId,
}
impl From<ProjectConflictError> for Error {
fn from(value: ProjectConflictError) -> Self {
Error::UserError {
code: crate::error::Code::ProjectConflict,
message: format!("project {} is in a conflicted state", value.project_id),
}
}
}
#[derive(Debug)]
pub struct DefaultTargetNotSetError {
pub project_id: ProjectId,
}
impl From<DefaultTargetNotSetError> for Error {
fn from(value: DefaultTargetNotSetError) -> Self {
Error::UserError {
code: crate::error::Code::ProjectConflict,
message: format!(
"project {} does not have a default target set",
value.project_id
),
}
}
}
#[derive(Debug)]
pub struct BranchNotFoundError {
pub project_id: ProjectId,
pub branch_id: BranchId,
}
impl From<BranchNotFoundError> for Error {
fn from(value: BranchNotFoundError) -> Self {
Error::UserError {
code: crate::error::Code::Branches,
message: format!("branch {} not found", value.branch_id),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum UpdateBranchError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("branch not found")]
BranchNotFound(BranchNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<UpdateBranchError> for Error {
fn from(value: UpdateBranchError) -> Self {
match value {
UpdateBranchError::DefaultTargetNotSet(error) => error.into(),
UpdateBranchError::BranchNotFound(error) => error.into(),
UpdateBranchError::Other(error) => {
tracing::error!(?error, "update branch error");
Error::Unknown
}
}
}
}
impl From<CreateVirtualBranchFromBranchError> for Error {
fn from(value: CreateVirtualBranchFromBranchError) -> Self {
match value {
CreateVirtualBranchFromBranchError::ApplyBranch(error) => error.into(),
CreateVirtualBranchFromBranchError::CantMakeBranchFromDefaultTarget => {
Error::UserError {
message: "Can not create a branch from default target".to_string(),
code: crate::error::Code::Branches,
}
}
CreateVirtualBranchFromBranchError::DefaultTargetNotSet(error) => error.into(),
CreateVirtualBranchFromBranchError::BranchNotFound(name) => Error::UserError {
message: format!("Branch {} not found", name),
code: crate::error::Code::Branches,
},
CreateVirtualBranchFromBranchError::Other(error) => {
tracing::error!(?error, "create virtual branch from branch error");
Error::Unknown
}
}
}
}
impl From<CommitError> for Error {
fn from(value: CommitError) -> Self {
match value {
CommitError::BranchNotFound(error) => error.into(),
CommitError::DefaultTargetNotSet(error) => error.into(),
CommitError::Conflicted(error) => error.into(),
CommitError::CommitHookRejected(error) => Error::UserError {
code: crate::error::Code::PreCommitHook,
message: error,
},
CommitError::CommitMsgHookRejected(error) => Error::UserError {
code: crate::error::Code::CommitMsgHook,
message: error,
},
CommitError::Other(error) => {
tracing::error!(?error, "commit error");
Error::Unknown
}
}
}
}
impl From<IsRemoteBranchMergableError> for Error {
fn from(value: IsRemoteBranchMergableError) -> Self {
match value {
IsRemoteBranchMergableError::BranchNotFound(name) => Error::UserError {
message: format!("Remote branch {} not found", name),
code: crate::error::Code::Branches,
},
IsRemoteBranchMergableError::DefaultTargetNotSet(error) => error.into(),
IsRemoteBranchMergableError::Other(error) => {
tracing::error!(?error, "is remote branch mergable error");
Error::Unknown
}
}
}
}
impl From<DeleteBranchError> for Error {
fn from(value: DeleteBranchError) -> Self {
match value {
DeleteBranchError::UnapplyBranch(error) => error.into(),
DeleteBranchError::Other(error) => {
tracing::error!(?error, "delete branch error");
Error::Unknown
}
}
}
}
impl From<ApplyBranchError> for Error {
fn from(value: ApplyBranchError) -> Self {
match value {
ApplyBranchError::DefaultTargetNotSet(error) => error.into(),
ApplyBranchError::Conflict(error) => error.into(),
ApplyBranchError::BranchNotFound(error) => error.into(),
ApplyBranchError::BranchConflicts(id) => Error::UserError {
message: format!("Branch {} is in a conflicing state", id),
code: crate::error::Code::Branches,
},
ApplyBranchError::Other(error) => {
tracing::error!(?error, "apply branch error");
Error::Unknown
}
}
}
}
impl From<IsVirtualBranchMergeable> for Error {
fn from(value: IsVirtualBranchMergeable) -> Self {
match value {
IsVirtualBranchMergeable::BranchNotFound(error) => error.into(),
IsVirtualBranchMergeable::DefaultTargetNotSet(error) => error.into(),
IsVirtualBranchMergeable::Other(error) => {
tracing::error!(?error, "is remote branch mergable error");
Error::Unknown
}
}
}
}
impl From<ListVirtualBranchesError> for Error {
fn from(value: ListVirtualBranchesError) -> Self {
match value {
ListVirtualBranchesError::DefaultTargetNotSet(error) => error.into(),
ListVirtualBranchesError::Other(error) => {
tracing::error!(?error, "list virtual branches error");
Error::Unknown
}
}
}
}
impl From<CreateVirtualBranchError> for Error {
fn from(value: CreateVirtualBranchError) -> Self {
match value {
CreateVirtualBranchError::DefaultTargetNotSet(error) => error.into(),
CreateVirtualBranchError::Other(error) => {
tracing::error!(?error, "create virtual branch error");
Error::Unknown
}
}
}
}
impl From<GetBaseBranchDataError> for Error {
fn from(value: GetBaseBranchDataError) -> Self {
match value {
GetBaseBranchDataError::Other(error) => {
tracing::error!(?error, "get base branch data error");
Error::Unknown
}
}
}
}
impl From<ListRemoteCommitFilesError> for Error {
fn from(value: ListRemoteCommitFilesError) -> Self {
match value {
ListRemoteCommitFilesError::CommitNotFound(oid) => Error::UserError {
message: format!("Commit {} not found", oid),
code: crate::error::Code::Branches,
},
ListRemoteCommitFilesError::Other(error) => {
tracing::error!(?error, "list remote commit files error");
Error::Unknown
}
}
}
}
impl From<SetBaseBranchError> for Error {
fn from(value: SetBaseBranchError) -> Self {
match value {
SetBaseBranchError::DirtyWorkingDirectory => Error::UserError {
message: "Current HEAD is dirty.".to_string(),
code: crate::error::Code::ProjectConflict,
},
SetBaseBranchError::BranchNotFound(name) => Error::UserError {
message: format!("remote branch '{}' not found", name),
code: crate::error::Code::Branches,
},
SetBaseBranchError::Other(error) => {
tracing::error!(?error, "set base branch error");
Error::Unknown
}
}
}
}
impl From<MergeVirtualBranchUpstreamError> for Error {
fn from(value: MergeVirtualBranchUpstreamError) -> Self {
match value {
MergeVirtualBranchUpstreamError::BranchNotFound(error) => error.into(),
MergeVirtualBranchUpstreamError::Conflict(error) => error.into(),
MergeVirtualBranchUpstreamError::Other(error) => {
tracing::error!(?error, "merge virtual branch upstream error");
Error::Unknown
}
}
}
}
impl From<UpdateBaseBranchError> for Error {
fn from(value: UpdateBaseBranchError) -> Self {
match value {
UpdateBaseBranchError::Conflict(error) => error.into(),
UpdateBaseBranchError::DefaultTargetNotSet(error) => error.into(),
UpdateBaseBranchError::Other(error) => {
tracing::error!(?error, "update base branch error");
Error::Unknown
}
}
}
}
impl From<UnapplyOwnershipError> for Error {
fn from(value: UnapplyOwnershipError) -> Self {
match value {
UnapplyOwnershipError::DefaultTargetNotSet(error) => error.into(),
UnapplyOwnershipError::Conflict(error) => error.into(),
UnapplyOwnershipError::Other(error) => {
tracing::error!(?error, "unapply ownership error");
Error::Unknown
}
}
}
}
impl From<AmendError> for Error {
fn from(value: AmendError) -> Self {
match value {
AmendError::ForcePushNotAllowed(error) => error.into(),
AmendError::Conflict(error) => error.into(),
AmendError::BranchNotFound(error) => error.into(),
AmendError::BranchHasNoCommits => Error::UserError {
message: "Branch has no commits - there is nothing to amend to".to_string(),
code: crate::error::Code::Branches,
},
AmendError::DefaultTargetNotSet(error) => error.into(),
AmendError::TargetOwnerhshipNotFound(_) => Error::UserError {
message: "target ownership not found".to_string(),
code: crate::error::Code::Branches,
},
AmendError::Other(error) => {
tracing::error!(?error, "amend error");
Error::Unknown
}
}
}
}
impl From<ResetBranchError> for Error {
fn from(value: ResetBranchError) -> Self {
match value {
ResetBranchError::BranchNotFound(error) => error.into(),
ResetBranchError::DefaultTargetNotSet(error) => error.into(),
ResetBranchError::CommitNotFoundInBranch(oid) => Error::UserError {
code: crate::error::Code::Branches,
message: format!("commit {} not found", oid),
},
ResetBranchError::Other(error) => {
tracing::error!(?error, "reset branch error");
Error::Unknown
}
}
}
}
impl From<UnapplyBranchError> for Error {
fn from(value: UnapplyBranchError) -> Self {
match value {
UnapplyBranchError::DefaultTargetNotSet(error) => error.into(),
UnapplyBranchError::BranchNotFound(error) => error.into(),
UnapplyBranchError::Other(error) => {
tracing::error!(?error, "unapply branch error");
Error::Unknown
}
}
}
}
impl From<PushError> for Error {
fn from(value: PushError) -> Self {
match value {
PushError::Remote(error) => error.into(),
PushError::BranchNotFound(error) => error.into(),
PushError::DefaultTargetNotSet(error) => error.into(),
PushError::Other(error) => {
tracing::error!(?error, "push error");
Error::Unknown
}
}
}
}
impl From<FlushAppliedVbranchesError> for Error {
fn from(value: FlushAppliedVbranchesError) -> Self {
match value {
FlushAppliedVbranchesError::Other(error) => {
tracing::error!(?error, "flush workspace error");
Error::Unknown
}
}
}
}
impl From<CherryPickError> for Error {
fn from(value: CherryPickError) -> Self {
match value {
CherryPickError::NotApplied => Error::UserError {
message: "can not cherry pick non applied branch".to_string(),
code: crate::error::Code::Branches,
},
CherryPickError::Conflict(error) => error.into(),
CherryPickError::CommitNotFound(oid) => Error::UserError {
message: format!("commit {oid} not found"),
code: crate::error::Code::Branches,
},
CherryPickError::Other(error) => {
tracing::error!(?error, "cherry pick error");
Error::Unknown
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ListRemoteCommitFilesError {
#[error("failed to find commit {0}")]
CommitNotFound(git::Oid),
#[error("failed to find commit")]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum ListRemoteBranchesError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum GetRemoteBranchDataError {
#[error("default target not set")]
DefaultTargetNotSet(DefaultTargetNotSetError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<GetRemoteBranchDataError> for Error {
fn from(value: GetRemoteBranchDataError) -> Self {
match value {
GetRemoteBranchDataError::DefaultTargetNotSet(error) => error.into(),
GetRemoteBranchDataError::Other(error) => {
tracing::error!(?error, "get remote branch data error");
Error::Unknown
}
}
}
}
impl From<ListRemoteBranchesError> for Error {
fn from(value: ListRemoteBranchesError) -> Self {
match value {
ListRemoteBranchesError::DefaultTargetNotSet(error) => error.into(),
ListRemoteBranchesError::Other(error) => {
tracing::error!(?error, "list remote branches error");
Error::Unknown
}
}
}
}
impl From<SquashError> for Error {
fn from(value: SquashError) -> Self {
match value {
SquashError::ForcePushNotAllowed(error) => error.into(),
SquashError::DefaultTargetNotSet(error) => error.into(),
SquashError::BranchNotFound(error) => error.into(),
SquashError::Conflict(error) => error.into(),
SquashError::CantSquashRootCommit => Error::UserError {
message: "can not squash root branch commit".to_string(),
code: crate::error::Code::Branches,
},
SquashError::CommitNotFound(oid) => Error::UserError {
message: format!("commit {oid} not found"),
code: crate::error::Code::Branches,
},
SquashError::Other(error) => {
tracing::error!(?error, "squash error");
Error::Unknown
}
}
}
}

View File

@ -0,0 +1,96 @@
use std::path;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::git::{self, diff, show};
use super::errors;
use crate::virtual_branches::context;
#[derive(Debug, PartialEq, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteBranchFile {
pub path: path::PathBuf,
pub hunks: Vec<diff::GitHunk>,
pub binary: bool,
}
pub fn list_remote_commit_files(
repository: &git::Repository,
commit_oid: git::Oid,
context_lines: u32,
) -> Result<Vec<RemoteBranchFile>, errors::ListRemoteCommitFilesError> {
let commit = match repository.find_commit(commit_oid) {
Ok(commit) => Ok(commit),
Err(git::Error::NotFound(_)) => Err(errors::ListRemoteCommitFilesError::CommitNotFound(
commit_oid,
)),
Err(error) => Err(errors::ListRemoteCommitFilesError::Other(error.into())),
}?;
if commit.parent_count() == 0 {
return Ok(vec![]);
}
let parent = commit.parent(0).context("failed to get parent commit")?;
let commit_tree = commit.tree().context("failed to get commit tree")?;
let parent_tree = parent.tree().context("failed to get parent tree")?;
let diff = diff::trees(repository, &parent_tree, &commit_tree, context_lines)?;
let diff = diff::diff_files_to_hunks(&diff);
let mut files = diff
.into_iter()
.map(|(file_path, hunks)| RemoteBranchFile {
path: file_path.clone(),
hunks: hunks.clone(),
binary: hunks.iter().any(|h| h.binary),
})
.collect::<Vec<_>>();
if context_lines == 0 {
files = files_with_hunk_context(repository, &parent_tree, files, 3)
.context("failed to add context to hunk")?;
}
Ok(files)
}
fn files_with_hunk_context(
repository: &git::Repository,
parent_tree: &git::Tree,
mut files: Vec<RemoteBranchFile>,
context_lines: usize,
) -> Result<Vec<RemoteBranchFile>> {
for file in &mut files {
if file.binary {
continue;
}
// Get file content as it looked before the diffs
let file_content_before =
show::show_file_at_tree(repository, file.path.clone(), parent_tree)
.context("failed to get file contents at HEAD")?;
let file_lines_before = file_content_before.split('\n').collect::<Vec<_>>();
file.hunks = file
.hunks
.iter()
.map(|hunk| {
if hunk.diff.is_empty() {
// noop on empty diff
hunk.clone()
} else {
context::hunk_with_context(
&hunk.diff,
hunk.old_start as usize,
hunk.new_start as usize,
hunk.binary,
context_lines,
&file_lines_before,
hunk.change_type,
)
}
})
.collect::<Vec<diff::GitHunk>>();
}
Ok(files)
}

View File

@ -0,0 +1,351 @@
use std::io::{Read, Write};
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use crate::{
gb_repository,
git::{self},
project_repository::{self, LogUntil},
reader, sessions,
virtual_branches::branch::BranchCreateRequest,
};
use super::errors;
lazy_static! {
pub static ref GITBUTLER_INTEGRATION_REFERENCE: git::LocalRefname =
git::LocalRefname::new("gitbutler/integration", None);
}
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler";
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com";
pub fn update_gitbutler_integration(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
) -> Result<()> {
let target = gb_repository
.default_target()
.context("failed to get target")?
.context("no target set")?;
let repo = &project_repository.git_repository;
// write the currrent target sha to a temp branch as a parent
repo.reference(
&GITBUTLER_INTEGRATION_REFERENCE.clone().into(),
target.sha,
true,
"update target",
)?;
// get commit object from target.sha
let target_commit = repo.find_commit(target.sha)?;
// get current repo head for reference
let head = repo.head()?;
let mut prev_head = head.name().unwrap().to_string();
let mut prev_sha = head.target().unwrap().to_string();
let integration_file = repo.path().join("integration");
if prev_head == GITBUTLER_INTEGRATION_REFERENCE.to_string() {
// read the .git/integration file
if let Ok(mut integration_file) = std::fs::File::open(integration_file) {
let mut prev_data = String::new();
integration_file.read_to_string(&mut prev_data)?;
let parts: Vec<&str> = prev_data.split(':').collect();
prev_head = parts[0].to_string();
prev_sha = parts[1].to_string();
}
} else {
// we are moving from a regular branch to our gitbutler integration branch, save the original
// write a file to .git/integration with the previous head and name
let mut file = std::fs::File::create(integration_file)?;
prev_head.push(':');
prev_head.push_str(&prev_sha);
file.write_all(prev_head.as_bytes())?;
}
// commit index to temp head for the merge
repo.set_head(&GITBUTLER_INTEGRATION_REFERENCE.clone().into())
.context("failed to set head")?;
let latest_session = gb_repository
.get_latest_session()
.context("failed to get latest session")?
.context("latest session not found")?;
let session_reader = sessions::Reader::open(gb_repository, &latest_session)
.context("failed to open current session")?;
// get all virtual branches, we need to try to update them all
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 base_tree = target_commit.tree()?;
let mut final_tree = target_commit.tree()?;
for branch in &applied_virtual_branches {
// merge this branches tree with our tree
let branch_head = repo.find_commit(branch.head)?;
let branch_tree = branch_head.tree()?;
if let Ok(mut result) = repo.merge_trees(&base_tree, &final_tree, &branch_tree) {
if !result.has_conflicts() {
let final_tree_oid = result.write_tree_to(repo)?;
final_tree = repo.find_tree(final_tree_oid)?;
}
}
}
// message that says how to get back to where they were
let mut message = "GitButler Integration Commit".to_string();
message.push_str("\n\n");
message.push_str(
"This is an integration commit for the virtual branches that GitButler is tracking.\n\n",
);
message.push_str(
"Due to GitButler managing multiple virtual branches, you cannot switch back and\n",
);
message.push_str("forth between git branches and virtual branches easily. \n\n");
message.push_str("If you switch to another branch, GitButler will need to be reinitialized.\n");
message.push_str("If you commit on this branch, GitButler will throw it away.\n\n");
message.push_str("Here are the branches that are currently applied:\n");
for branch in &applied_virtual_branches {
message.push_str(" - ");
message.push_str(branch.name.as_str());
message.push_str(format!(" ({})", &branch.refname()).as_str());
message.push('\n');
if branch.head != target.sha {
message.push_str(" branch head: ");
message.push_str(&branch.head.to_string());
message.push('\n');
}
for file in &branch.ownership.claims {
message.push_str(" - ");
message.push_str(&file.file_path.display().to_string());
message.push('\n');
}
}
message.push_str("\nYour previous branch was: ");
message.push_str(&prev_head);
message.push_str("\n\n");
message.push_str("The sha for that commit was: ");
message.push_str(&prev_sha);
message.push_str("\n\n");
message.push_str("For more information about what we're doing here, check out our docs:\n");
message.push_str("https://docs.gitbutler.com/features/virtual-branches/integration-branch\n");
let committer = git::Signature::now(
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL,
)?;
repo.commit(
Some(&"refs/heads/gitbutler/integration".parse().unwrap()),
&committer,
&committer,
&message,
&final_tree,
&[&target_commit],
)?;
// write final_tree as the current index
let mut index = repo.index()?;
index.read_tree(&final_tree)?;
index.write()?;
// finally, update the refs/gitbutler/ heads to the states of the current virtual branches
for branch in &all_virtual_branches {
let wip_tree = repo.find_tree(branch.tree)?;
let mut branch_head = repo.find_commit(branch.head)?;
let head_tree = branch_head.tree()?;
// create a wip commit if there is wip
if head_tree.id() != wip_tree.id() {
let mut message = "GitButler WIP Commit".to_string();
message.push_str("\n\n");
message.push_str("This is a WIP commit for the virtual branch '");
message.push_str(branch.name.as_str());
message.push_str("'\n\n");
message.push_str("This commit is used to store the state of the virtual branch\n");
message.push_str("while you are working on it. It is not meant to be used for\n");
message.push_str("anything else.\n\n");
let branch_head_oid = repo.commit(
None,
&committer,
&committer,
&message,
&wip_tree,
&[&branch_head],
)?;
branch_head = repo.find_commit(branch_head_oid)?;
}
repo.reference(
&branch.refname().into(),
branch_head.id(),
true,
"update virtual branch",
)?;
}
Ok(())
}
pub fn verify_branch(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
) -> Result<(), errors::VerifyError> {
verify_head_is_set(project_repository)?;
verify_head_is_clean(gb_repository, project_repository)?;
Ok(())
}
fn verify_head_is_clean(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
) -> Result<(), errors::VerifyError> {
let head_commit = project_repository
.git_repository
.head()
.context("failed to get head")?
.peel_to_commit()
.context("failed to peel to commit")?;
let mut extra_commits = project_repository
.log(
head_commit.id(),
LogUntil::When(Box::new(|commit| Ok(is_integration_commit(commit)))),
)
.context("failed to get log")?;
let integration_commit = extra_commits.pop();
if integration_commit.is_none() {
// no integration commit found
return Err(errors::VerifyError::NoIntegrationCommit);
}
if extra_commits.is_empty() {
// no extra commits found, so we're good
return Ok(());
}
project_repository
.git_repository
.reset(
integration_commit.as_ref().unwrap(),
git2::ResetType::Soft,
None,
)
.context("failed to reset to integration commit")?;
let mut new_branch = super::create_virtual_branch(
gb_repository,
project_repository,
&BranchCreateRequest {
name: extra_commits
.last()
.unwrap()
.message()
.map(ToString::to_string),
..Default::default()
},
)
.context("failed to create virtual branch")?;
// rebasing the extra commits onto the new branch
let writer = super::branch::Writer::new(gb_repository, project_repository.project().gb_dir())
.context("failed to create writer")?;
extra_commits.reverse();
let mut head = new_branch.head;
for commit in extra_commits {
let new_branch_head = project_repository
.git_repository
.find_commit(head)
.context("failed to find new branch head")?;
let rebased_commit_oid = project_repository
.git_repository
.commit(
None,
&commit.author(),
&commit.committer(),
commit.message().unwrap(),
&commit.tree().unwrap(),
&[&new_branch_head],
)
.context(format!(
"failed to rebase commit {} onto new branch",
commit.id()
))?;
let rebased_commit = project_repository
.git_repository
.find_commit(rebased_commit_oid)
.context(format!(
"failed to find rebased commit {}",
rebased_commit_oid
))?;
new_branch.head = rebased_commit.id();
new_branch.tree = rebased_commit.tree_id();
writer
.write(&mut new_branch)
.context("failed to write branch")?;
head = rebased_commit.id();
}
Ok(())
}
fn verify_head_is_set(
project_repository: &project_repository::Repository,
) -> Result<(), errors::VerifyError> {
match project_repository
.get_head()
.context("failed to get head")
.map_err(errors::VerifyError::Other)?
.name()
{
Some(refname) if refname.to_string() == GITBUTLER_INTEGRATION_REFERENCE.to_string() => {
Ok(())
}
None => Err(errors::VerifyError::DetachedHead),
Some(head_name) => Err(errors::VerifyError::InvalidHead(head_name.to_string())),
}
}
fn is_integration_commit(commit: &git::Commit) -> bool {
is_integration_commit_author(commit) && is_integration_commit_message(commit)
}
fn is_integration_commit_author(commit: &git::Commit) -> bool {
is_integration_commit_author_email(commit) && is_integration_commit_author_name(commit)
}
fn is_integration_commit_author_email(commit: &git::Commit) -> bool {
commit.author().email().map_or(false, |email| {
email == GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL
})
}
fn is_integration_commit_author_name(commit: &git::Commit) -> bool {
commit.author().name().map_or(false, |name| {
name == GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME
})
}
fn is_integration_commit_message(commit: &git::Commit) -> bool {
commit.message().map_or(false, |message| {
message.starts_with("GitButler Integration Commit")
})
}

Some files were not shown because too many files have changed in this diff Show More