mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-26 12:24:26 +03:00
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:
parent
326a5a00b3
commit
bc2fff968c
61
Cargo.lock
generated
61
Cargo.lock
generated
@ -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"
|
||||
|
84
Cargo.toml
84
Cargo.toml
@ -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" ] }
|
||||
|
@ -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
63
src/askpass.rs
Normal 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
204
src/assets.rs
Normal 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
48
src/database.rs
Normal 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)
|
||||
}
|
||||
}
|
12
src/database/migrations/V0__deltas.sql
Normal file
12
src/database/migrations/V0__deltas.sql
Normal 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`);
|
11
src/database/migrations/V1__sessions.sql
Normal file
11
src/database/migrations/V1__sessions.sql
Normal 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`);
|
14
src/database/migrations/V2__files.sql
Normal file
14
src/database/migrations/V2__files.sql
Normal 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
|
||||
);
|
8
src/database/migrations/V3__bookmarks.sql
Normal file
8
src/database/migrations/V3__bookmarks.sql
Normal 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`);
|
16
src/database/migrations/V4__bookmarks_update.sql
Normal file
16
src/database/migrations/V4__bookmarks_update.sql
Normal 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;
|
28
src/database/migrations/V5__bookmarks_update.sql
Normal file
28
src/database/migrations/V5__bookmarks_update.sql
Normal 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;
|
@ -0,0 +1 @@
|
||||
CREATE INDEX `sessions_project_id_id_index` ON `sessions` (`project_id`, `id`);
|
2
src/database/migrations/V7__drop_files.sql
Normal file
2
src/database/migrations/V7__drop_files.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP TABLE files;
|
||||
DROP TABLE contents;
|
1
src/database/migrations/V8__drop_bookmarks.sql
Normal file
1
src/database/migrations/V8__drop_bookmarks.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE bookmarks;
|
45
src/dedup.rs
Normal file
45
src/dedup.rs
Normal 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
15
src/deltas.rs
Normal 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
33
src/deltas/controller.rs
Normal 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
122
src/deltas/database.rs
Normal 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
9
src/deltas/delta.rs
Normal 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
85
src/deltas/document.rs
Normal 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
116
src/deltas/operations.rs
Normal 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
89
src/deltas/reader.rs
Normal 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
73
src/deltas/writer.rs
Normal 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
414
src/error.rs
Normal 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
89
src/error/sentry.rs
Normal 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
30
src/fs.rs
Normal 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
3
src/gb_repository.rs
Normal file
@ -0,0 +1,3 @@
|
||||
mod repository;
|
||||
|
||||
pub use repository::{RemoteError, Repository};
|
967
src/gb_repository/repository.rs
Normal file
967
src/gb_repository/repository.rs
Normal 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, ¤t_session, user)
|
||||
.context(format!("failed to flush session {}", current_session.id))?;
|
||||
Ok(Some(current_session))
|
||||
}
|
||||
|
||||
pub fn flush_session(
|
||||
&self,
|
||||
project_repository: &project_repository::Repository,
|
||||
session: &sessions::Session,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<sessions::Session> {
|
||||
if session.hash.is_some() {
|
||||
return Ok(session.clone());
|
||||
}
|
||||
|
||||
if !self.root().exists() {
|
||||
return Err(anyhow!("nothing to flush"));
|
||||
}
|
||||
|
||||
let _lock = self.lock();
|
||||
|
||||
// update last timestamp
|
||||
let session_writer =
|
||||
sessions::Writer::new(self).context("failed to create session writer")?;
|
||||
session_writer.write(session)?;
|
||||
|
||||
let mut tree_builder = self.git_repository.treebuilder(None);
|
||||
|
||||
tree_builder.upsert(
|
||||
"session",
|
||||
build_session_tree(self).context("failed to build session tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
tree_builder.upsert(
|
||||
"wd",
|
||||
build_wd_tree(self, project_repository)
|
||||
.context("failed to build working directory tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
tree_builder.upsert(
|
||||
"branches",
|
||||
build_branches_tree(self).context("failed to build branches tree")?,
|
||||
git::FileMode::Tree,
|
||||
);
|
||||
|
||||
let tree_id = tree_builder.write().context("failed to write tree")?;
|
||||
|
||||
let commit_oid =
|
||||
write_gb_commit(tree_id, self, user).context("failed to write gb commit")?;
|
||||
|
||||
tracing::info!(
|
||||
project_id = %self.project.id,
|
||||
session_id = %session.id,
|
||||
%commit_oid,
|
||||
"flushed session"
|
||||
);
|
||||
|
||||
session_writer.remove()?;
|
||||
|
||||
let session = sessions::Session {
|
||||
hash: Some(commit_oid),
|
||||
..session.clone()
|
||||
};
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn get_sessions_iterator(&self) -> Result<sessions::SessionsIterator<'_>> {
|
||||
sessions::SessionsIterator::new(&self.git_repository)
|
||||
}
|
||||
|
||||
pub fn get_current_session(&self) -> Result<Option<sessions::Session>> {
|
||||
let _lock = self.lock();
|
||||
let reader = reader::Reader::open(&self.root())?;
|
||||
match sessions::Session::try_from(&reader) {
|
||||
Ok(session) => Ok(Some(session)),
|
||||
Err(sessions::SessionError::NoSession) => Ok(None),
|
||||
Err(sessions::SessionError::Other(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> std::path::PathBuf {
|
||||
self.git_repository.path().join("gitbutler")
|
||||
}
|
||||
|
||||
pub fn session_path(&self) -> std::path::PathBuf {
|
||||
self.root().join("session")
|
||||
}
|
||||
|
||||
pub fn git_repository_path(&self) -> &std::path::Path {
|
||||
self.git_repository.path()
|
||||
}
|
||||
|
||||
pub fn session_wd_path(&self) -> std::path::PathBuf {
|
||||
self.session_path().join("wd")
|
||||
}
|
||||
|
||||
pub fn default_target(&self) -> Result<Option<target::Target>> {
|
||||
if let Some(latest_session) = self.get_latest_session()? {
|
||||
let latest_session_reader = sessions::Reader::open(self, &latest_session)
|
||||
.context("failed to open current session")?;
|
||||
let target_reader = target::Reader::new(&latest_session_reader);
|
||||
match target_reader.read_default() {
|
||||
Result::Ok(target) => Ok(Some(target)),
|
||||
Err(reader::Error::NotFound) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_gitbutler_file(&self, session_id: &SessionId) -> Result<()> {
|
||||
let gb_path = self.git_repository.path();
|
||||
let project_id = self.project.id.to_string();
|
||||
let gb_file_content = serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"repositoryId": project_id,
|
||||
"gbPath": gb_path,
|
||||
"api": self.project.api,
|
||||
});
|
||||
|
||||
let gb_file_path = self.project.path.join(".git/gitbutler.json");
|
||||
std::fs::write(&gb_file_path, gb_file_content.to_string())?;
|
||||
|
||||
tracing::debug!("gitbutler file updated: {:?}", gb_file_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_repository(&self) -> &git::Repository {
|
||||
&self.git_repository
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wd_tree(
|
||||
gb_repository: &Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<git::Oid> {
|
||||
match gb_repository
|
||||
.git_repository
|
||||
.find_reference(&"refs/heads/current".parse().unwrap())
|
||||
{
|
||||
Result::Ok(reference) => build_wd_tree_from_reference(gb_repository, &reference)
|
||||
.context("failed to build wd index"),
|
||||
Err(git::Error::NotFound(_)) => build_wd_tree_from_repo(gb_repository, project_repository)
|
||||
.context("failed to build wd index"),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wd_tree_from_reference(
|
||||
gb_repository: &Repository,
|
||||
reference: &git::Reference,
|
||||
) -> Result<git::Oid> {
|
||||
// start off with the last tree as a base
|
||||
let tree = reference.peel_to_tree()?;
|
||||
let wd_tree_entry = tree.get_name("wd").unwrap();
|
||||
let wd_tree = gb_repository.git_repository.find_tree(wd_tree_entry.id())?;
|
||||
let mut index = git::Index::try_from(&wd_tree)?;
|
||||
|
||||
// write updated files on top of the last tree
|
||||
for file_path in fs::list_files(gb_repository.session_wd_path(), &[]).with_context(|| {
|
||||
format!(
|
||||
"failed to session working directory files list files in {}",
|
||||
gb_repository.session_wd_path().display()
|
||||
)
|
||||
})? {
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
&gb_repository.session_wd_path(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add session working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let session_reader = reader::Reader::open(&gb_repository.root())?;
|
||||
let deltas = deltas::Reader::from(&session_reader)
|
||||
.read(None)
|
||||
.context("failed to read deltas")?;
|
||||
let wd_files = session_reader.list_files(path::Path::new("session/wd"))?;
|
||||
let wd_files = wd_files.iter().collect::<HashSet<_>>();
|
||||
|
||||
// if a file has delta, but doesn't exist in wd, it was deleted
|
||||
let deleted_files = deltas
|
||||
.keys()
|
||||
.filter(|key| !wd_files.contains(key))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for deleted_file in deleted_files {
|
||||
index
|
||||
.remove_path(deleted_file)
|
||||
.context("failed to remove path")?;
|
||||
}
|
||||
|
||||
let wd_tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write wd tree")?;
|
||||
Ok(wd_tree_oid)
|
||||
}
|
||||
|
||||
// build wd index from the working directory files new session wd files
|
||||
// this is important because we want to make sure session files are in sync with session deltas
|
||||
fn build_wd_tree_from_repo(
|
||||
gb_repository: &Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
let mut added: HashMap<String, bool> = HashMap::new();
|
||||
|
||||
// first, add session/wd files. session/wd are written at the same time as deltas, so it's important to add them first
|
||||
// to make sure they are in sync with the deltas
|
||||
for file_path in fs::list_files(gb_repository.session_wd_path(), &[]).with_context(|| {
|
||||
format!(
|
||||
"failed to session working directory files list files in {}",
|
||||
gb_repository.session_wd_path().display()
|
||||
)
|
||||
})? {
|
||||
if project_repository
|
||||
.git_repository
|
||||
.is_path_ignored(&file_path)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
&gb_repository.session_wd_path(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add session working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
added.insert(file_path.to_string_lossy().to_string(), true);
|
||||
}
|
||||
|
||||
// finally, add files from the working directory if they aren't already in the index
|
||||
for file_path in fs::list_files(project_repository.root(), &[path::Path::new(".git")])
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to working directory list files in {}",
|
||||
project_repository.root().display()
|
||||
)
|
||||
})?
|
||||
{
|
||||
if added.contains_key(&file_path.to_string_lossy().to_string()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if project_repository
|
||||
.git_repository
|
||||
.is_path_ignored(&file_path)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
add_wd_path(
|
||||
&mut index,
|
||||
project_repository.root(),
|
||||
&file_path,
|
||||
gb_repository,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to add working directory path {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write tree to repo")?;
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
// take a file path we see and add it to our in-memory index
|
||||
// we call this from build_initial_wd_tree, which is smart about using the existing index to avoid rehashing files that haven't changed
|
||||
// and also looks for large files and puts in a placeholder hash in the LFS format
|
||||
// TODO: actually upload the file to LFS
|
||||
fn add_wd_path(
|
||||
index: &mut git::Index,
|
||||
dir: &std::path::Path,
|
||||
rel_file_path: &std::path::Path,
|
||||
gb_repository: &Repository,
|
||||
) -> Result<()> {
|
||||
let file_path = dir.join(rel_file_path);
|
||||
|
||||
let metadata = std::fs::symlink_metadata(&file_path).context("failed to get metadata for")?;
|
||||
let modify_time = FileTime::from_last_modification_time(&metadata);
|
||||
let create_time = FileTime::from_creation_time(&metadata).unwrap_or(modify_time);
|
||||
|
||||
// look for files that are bigger than 4GB, which are not supported by git
|
||||
// insert a pointer as the blob content instead
|
||||
// TODO: size limit should be configurable
|
||||
let blob = if metadata.is_symlink() {
|
||||
// it's a symlink, make the content the path of the link
|
||||
let link_target = std::fs::read_link(&file_path)?;
|
||||
// if the link target is inside the project repository, make it relative
|
||||
let link_target = link_target.strip_prefix(dir).unwrap_or(&link_target);
|
||||
gb_repository.git_repository.blob(
|
||||
link_target
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::InvalidUnicodePath(link_target.into()))?
|
||||
.as_bytes(),
|
||||
)?
|
||||
} else if metadata.len() > 100_000_000 {
|
||||
tracing::warn!(
|
||||
project_id = %gb_repository.project.id,
|
||||
path = %file_path.display(),
|
||||
"file too big"
|
||||
);
|
||||
|
||||
// get a sha256 hash of the file first
|
||||
let sha = sha256_digest(&file_path)?;
|
||||
|
||||
// put togther a git lfs pointer file: https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md
|
||||
let mut lfs_pointer = String::from("version https://git-lfs.github.com/spec/v1\n");
|
||||
lfs_pointer.push_str("oid sha256:");
|
||||
lfs_pointer.push_str(&sha);
|
||||
lfs_pointer.push('\n');
|
||||
lfs_pointer.push_str("size ");
|
||||
lfs_pointer.push_str(&metadata.len().to_string());
|
||||
lfs_pointer.push('\n');
|
||||
|
||||
// write the file to the .git/lfs/objects directory
|
||||
// create the directory recursively if it doesn't exist
|
||||
let lfs_objects_dir = gb_repository.git_repository.path().join("lfs/objects");
|
||||
std::fs::create_dir_all(lfs_objects_dir.clone())?;
|
||||
let lfs_path = lfs_objects_dir.join(sha);
|
||||
std::fs::copy(file_path, lfs_path)?;
|
||||
|
||||
gb_repository.git_repository.blob(lfs_pointer.as_bytes())?
|
||||
} else {
|
||||
// read the file into a blob, get the object id
|
||||
gb_repository.git_repository.blob_path(&file_path)?
|
||||
};
|
||||
|
||||
// create a new IndexEntry from the file metadata
|
||||
// truncation is ok https://libgit2.org/libgit2/#HEAD/type/git_index_entry
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
index
|
||||
.add(&git::IndexEntry {
|
||||
ctime: create_time,
|
||||
mtime: modify_time,
|
||||
dev: metadata.dev() as u32,
|
||||
ino: metadata.ino() as u32,
|
||||
mode: 33188,
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
file_size: metadata.len() as u32,
|
||||
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
|
||||
flags_extended: 0, // no extended flags
|
||||
path: rel_file_path.to_str().unwrap().to_string().into(),
|
||||
id: blob,
|
||||
})
|
||||
.with_context(|| format!("failed to add index entry for {}", rel_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// calculates sha256 digest of a large file as lowercase hex string via streaming buffer
|
||||
/// used to calculate the hash of large files that are not supported by git
|
||||
fn sha256_digest(path: &std::path::Path) -> Result<String> {
|
||||
let input = File::open(path)?;
|
||||
let mut reader = BufReader::new(input);
|
||||
|
||||
let digest = {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0; 1024];
|
||||
loop {
|
||||
let count = reader.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
hasher.finalize()
|
||||
};
|
||||
Ok(format!("{:X}", digest))
|
||||
}
|
||||
|
||||
fn build_branches_tree(gb_repository: &Repository) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
let branches_dir = gb_repository.root().join("branches");
|
||||
for file_path in
|
||||
fs::list_files(&branches_dir, &[]).context("failed to find branches directory")?
|
||||
{
|
||||
let file_path = std::path::Path::new(&file_path);
|
||||
add_file_to_index(
|
||||
gb_repository,
|
||||
&mut index,
|
||||
file_path,
|
||||
&branches_dir.join(file_path),
|
||||
)
|
||||
.context("failed to add branch file to index")?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write index to tree")?;
|
||||
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
fn build_session_tree(gb_repository: &Repository) -> Result<git::Oid> {
|
||||
let mut index = git::Index::new()?;
|
||||
|
||||
// add all files in the working directory to the in-memory index, skipping for matching entries in the repo index
|
||||
for file_path in fs::list_files(
|
||||
gb_repository.session_path(),
|
||||
&[path::Path::new("wd").to_path_buf()],
|
||||
)
|
||||
.context("failed to list session files")?
|
||||
{
|
||||
add_file_to_index(
|
||||
gb_repository,
|
||||
&mut index,
|
||||
&file_path,
|
||||
&gb_repository.session_path().join(&file_path),
|
||||
)
|
||||
.with_context(|| format!("failed to add session file: {}", file_path.display()))?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree_to(&gb_repository.git_repository)
|
||||
.context("failed to write index to tree")?;
|
||||
|
||||
Ok(tree_oid)
|
||||
}
|
||||
|
||||
// this is a helper function for build_gb_tree that takes paths under .git/gb/session and adds them to the in-memory index
|
||||
fn add_file_to_index(
|
||||
gb_repository: &Repository,
|
||||
index: &mut git::Index,
|
||||
rel_file_path: &std::path::Path,
|
||||
abs_file_path: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let blob = gb_repository.git_repository.blob_path(abs_file_path)?;
|
||||
let metadata = abs_file_path.metadata()?;
|
||||
let modified_time = FileTime::from_last_modification_time(&metadata);
|
||||
let create_time = FileTime::from_creation_time(&metadata).unwrap_or(modified_time);
|
||||
|
||||
// create a new IndexEntry from the file metadata
|
||||
// truncation is ok https://libgit2.org/libgit2/#HEAD/type/git_index_entry
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
index
|
||||
.add(&git::IndexEntry {
|
||||
ctime: create_time,
|
||||
mtime: modified_time,
|
||||
dev: metadata.dev() as u32,
|
||||
ino: metadata.ino() as u32,
|
||||
mode: 33188,
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
file_size: metadata.len() as u32,
|
||||
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
|
||||
flags_extended: 0, // no extended flags
|
||||
path: rel_file_path.to_str().unwrap().into(),
|
||||
id: blob,
|
||||
})
|
||||
.with_context(|| format!("Failed to add file to index: {}", abs_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// write a new commit object to the repo
|
||||
// this is called once we have a tree of deltas, metadata and current wd snapshot
|
||||
// and either creates or updates the refs/heads/current ref
|
||||
fn write_gb_commit(
|
||||
tree_id: git::Oid,
|
||||
gb_repository: &Repository,
|
||||
user: Option<&users::User>,
|
||||
) -> Result<git::Oid> {
|
||||
let comitter = git::Signature::now("gitbutler", "gitbutler@localhost")?;
|
||||
let author = match user {
|
||||
None => comitter.clone(),
|
||||
Some(user) => git::Signature::try_from(user)?,
|
||||
};
|
||||
|
||||
let current_refname: git::Refname = "refs/heads/current".parse().unwrap();
|
||||
|
||||
match gb_repository
|
||||
.git_repository
|
||||
.find_reference(¤t_refname)
|
||||
{
|
||||
Result::Ok(reference) => {
|
||||
let last_commit = reference.peel_to_commit()?;
|
||||
let new_commit = gb_repository.git_repository.commit(
|
||||
Some(¤t_refname),
|
||||
&author, // author
|
||||
&comitter, // committer
|
||||
"gitbutler check", // commit message
|
||||
&gb_repository.git_repository.find_tree(tree_id).unwrap(), // tree
|
||||
&[&last_commit], // parents
|
||||
)?;
|
||||
Ok(new_commit)
|
||||
}
|
||||
Err(git::Error::NotFound(_)) => {
|
||||
let new_commit = gb_repository.git_repository.commit(
|
||||
Some(¤t_refname),
|
||||
&author, // author
|
||||
&comitter, // committer
|
||||
"gitbutler check", // commit message
|
||||
&gb_repository.git_repository.find_tree(tree_id).unwrap(), // tree
|
||||
&[], // parents
|
||||
)?;
|
||||
Ok(new_commit)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteError {
|
||||
#[error("network error")]
|
||||
Network,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
42
src/git.rs
Normal file
42
src/git.rs
Normal 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
17
src/git/blob.rs
Normal 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
53
src/git/branch.rs
Normal 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
75
src/git/commit.rs
Normal 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
68
src/git/config.rs
Normal 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
392
src/git/credentials.rs
Normal 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
421
src/git/diff.rs
Normal 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
62
src/git/error.rs
Normal 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
164
src/git/index.rs
Normal 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
61
src/git/oid.rs
Normal 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
64
src/git/reference.rs
Normal 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()
|
||||
}
|
||||
}
|
137
src/git/reference/refname.rs
Normal file
137
src/git/reference/refname.rs
Normal 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)
|
||||
}
|
||||
}
|
17
src/git/reference/refname/error.rs
Normal file
17
src/git/reference/refname/error.rs
Normal 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),
|
||||
}
|
94
src/git/reference/refname/local.rs
Normal file
94
src/git/reference/refname/local.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
93
src/git/reference/refname/remote.rs
Normal file
93
src/git/reference/refname/remote.rs
Normal 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()
|
||||
}
|
||||
}
|
65
src/git/reference/refname/virtual.rs
Normal file
65
src/git/reference/refname/virtual.rs
Normal 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
43
src/git/remote.rs
Normal 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
535
src/git/repository.rs
Normal 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
22
src/git/show.rs
Normal 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
67
src/git/signature.rs
Normal 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
147
src/git/tree.rs
Normal 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
91
src/git/url.rs
Normal 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
128
src/git/url/convert.rs
Normal 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
147
src/git/url/parse.rs
Normal 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
54
src/git/url/scheme.rs
Normal 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
118
src/id.rs
Normal 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
6
src/keys.rs
Normal 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
34
src/keys/controller.rs
Normal 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
127
src/keys/key.rs
Normal 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
43
src/keys/storage.rs
Normal 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
41
src/lib.rs
Normal 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
51
src/lock.rs
Normal 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
48
src/path.rs
Normal 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
|
||||
}
|
||||
}
|
8
src/project_repository.rs
Normal file
8
src/project_repository.rs
Normal 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;
|
51
src/project_repository/config.rs
Normal file
51
src/project_repository/config.rs
Normal 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)
|
||||
}
|
||||
}
|
144
src/project_repository/conflicts.rs
Normal file
144
src/project_repository/conflicts.rs
Normal 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(())
|
||||
}
|
698
src/project_repository/repository.rs
Normal file
698
src/project_repository/repository.rs
Normal 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
|
||||
}
|
||||
}
|
22
src/project_repository/signatures.rs
Normal file
22
src/project_repository/signatures.rs
Normal 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
9
src/projects.rs
Normal 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
344
src/projects/controller.rs
Normal 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
112
src/projects/project.rs
Normal 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
162
src/projects/storage.rs
Normal 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
443
src/reader.rs
Normal 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
14
src/sessions.rs
Normal 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;
|
91
src/sessions/controller.rs
Normal file
91
src/sessions/controller.rs
Normal 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
182
src/sessions/database.rs
Normal 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
68
src/sessions/iterator.rs
Normal 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
105
src/sessions/reader.rs
Normal 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
126
src/sessions/session.rs
Normal 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
108
src/sessions/writer.rs
Normal 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
67
src/ssh.rs
Normal 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
73
src/storage.rs
Normal 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
1
src/types.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod default_true;
|
90
src/types/default_true.rs
Normal file
90
src/types/default_true.rs
Normal 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
6
src/users.rs
Normal 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
57
src/users/controller.rs
Normal 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
46
src/users/storage.rs
Normal 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
35
src/users/user.rs
Normal 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
29
src/virtual_branches.rs
Normal 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;
|
657
src/virtual_branches/base.rs
Normal file
657
src/virtual_branches/base.rs
Normal 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, ¤t_head_commit.id(), context_lines)?;
|
||||
let wd_diff = diff::diff_files_to_hunks(&wd_diff);
|
||||
if !wd_diff.is_empty() || current_head_commit.id() != target.sha {
|
||||
let hunks_by_filepath =
|
||||
super::virtual_hunks_by_filepath(&project_repository.project().path, &wd_diff);
|
||||
|
||||
// assign ownership to the branch
|
||||
let ownership = hunks_by_filepath.values().flatten().fold(
|
||||
BranchOwnershipClaims::default(),
|
||||
|mut ownership, hunk| {
|
||||
ownership.put(
|
||||
&format!("{}:{}", hunk.file_path.display(), hunk.id)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
ownership
|
||||
},
|
||||
);
|
||||
|
||||
let now_ms = time::UNIX_EPOCH
|
||||
.elapsed()
|
||||
.context("failed to get elapsed time")?
|
||||
.as_millis();
|
||||
|
||||
let (upstream, upstream_head) = if let git::Refname::Local(head_name) = &head_name {
|
||||
let upstream_name = target_branch_ref.with_branch(head_name.branch());
|
||||
if upstream_name.eq(target_branch_ref) {
|
||||
(None, None)
|
||||
} else {
|
||||
match repo.find_reference(&git::Refname::from(&upstream_name)) {
|
||||
Ok(upstream) => {
|
||||
let head = upstream
|
||||
.peel_to_commit()
|
||||
.map(|commit| commit.id())
|
||||
.context(format!(
|
||||
"failed to peel upstream {} to commit",
|
||||
upstream.name().unwrap()
|
||||
))?;
|
||||
Ok((Some(upstream_name), Some(head)))
|
||||
}
|
||||
Err(git::Error::NotFound(_)) => Ok((None, None)),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
.context(format!("failed to find upstream for {}", head_name))?
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut branch = branch::Branch {
|
||||
id: BranchId::generate(),
|
||||
name: head_name.to_string().replace("refs/heads/", ""),
|
||||
notes: String::new(),
|
||||
applied: true,
|
||||
upstream,
|
||||
upstream_head,
|
||||
created_timestamp_ms: now_ms,
|
||||
updated_timestamp_ms: now_ms,
|
||||
head: current_head_commit.id(),
|
||||
tree: super::write_tree_onto_commit(
|
||||
project_repository,
|
||||
current_head_commit.id(),
|
||||
&wd_diff,
|
||||
)?,
|
||||
ownership,
|
||||
order: 0,
|
||||
selected_for_changes: None,
|
||||
};
|
||||
|
||||
let branch_writer =
|
||||
branch::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create branch writer")?;
|
||||
branch_writer.write(&mut branch)?;
|
||||
}
|
||||
}
|
||||
|
||||
set_exclude_decoration(project_repository)?;
|
||||
|
||||
super::integration::update_gitbutler_integration(gb_repository, project_repository)?;
|
||||
|
||||
let base = target_to_base_branch(project_repository, &target)?;
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn set_exclude_decoration(project_repository: &project_repository::Repository) -> Result<()> {
|
||||
let repo = &project_repository.git_repository;
|
||||
let mut config = repo.config()?;
|
||||
config
|
||||
.set_multivar("log.excludeDecoration", "refs/gitbutler", "refs/gitbutler")
|
||||
.context("failed to set log.excludeDecoration")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _print_tree(repo: &git2::Repository, tree: &git2::Tree) -> Result<()> {
|
||||
println!("tree id: {}", tree.id());
|
||||
for entry in tree {
|
||||
println!(
|
||||
" entry: {} {}",
|
||||
entry.name().unwrap_or_default(),
|
||||
entry.id()
|
||||
);
|
||||
// get entry contents
|
||||
let object = entry.to_object(repo).context("failed to get object")?;
|
||||
let blob = object.as_blob().context("failed to get blob")?;
|
||||
// convert content to string
|
||||
if let Ok(content) = std::str::from_utf8(blob.content()) {
|
||||
println!(" blob: {}", content);
|
||||
} else {
|
||||
println!(" blob: BINARY");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// try to update the target branch
|
||||
// this means that we need to:
|
||||
// determine if what the target branch is now pointing to is mergeable with our current working directory
|
||||
// merge the target branch into our current working directory
|
||||
// update the target sha
|
||||
pub fn update_base_branch(
|
||||
gb_repository: &gb_repository::Repository,
|
||||
project_repository: &project_repository::Repository,
|
||||
user: Option<&users::User>,
|
||||
signing_key: Option<&keys::PrivateKey>,
|
||||
) -> Result<(), errors::UpdateBaseBranchError> {
|
||||
if project_repository.is_resolving() {
|
||||
return Err(errors::UpdateBaseBranchError::Conflict(
|
||||
errors::ProjectConflictError {
|
||||
project_id: project_repository.project().id,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// look up the target and see if there is a new oid
|
||||
let target = gb_repository
|
||||
.default_target()
|
||||
.context("failed to get default target")?
|
||||
.ok_or_else(|| {
|
||||
errors::UpdateBaseBranchError::DefaultTargetNotSet(errors::DefaultTargetNotSetError {
|
||||
project_id: project_repository.project().id,
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo = &project_repository.git_repository;
|
||||
let target_branch = repo
|
||||
.find_branch(&target.branch.clone().into())
|
||||
.context(format!("failed to find branch {}", target.branch))?;
|
||||
|
||||
let new_target_commit = target_branch
|
||||
.peel_to_commit()
|
||||
.context(format!("failed to peel branch {} to commit", target.branch))?;
|
||||
|
||||
// if the target has not changed, do nothing
|
||||
if new_target_commit.id() == target.sha {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ok, target has changed, so now we need to merge it into our current work and update our branches
|
||||
|
||||
// get tree from new target
|
||||
let new_target_tree = new_target_commit
|
||||
.tree()
|
||||
.context("failed to get new target commit tree")?;
|
||||
|
||||
let old_target_tree = repo
|
||||
.find_commit(target.sha)
|
||||
.and_then(|commit| commit.tree())
|
||||
.context(format!(
|
||||
"failed to get old target commit tree {}",
|
||||
target.sha
|
||||
))?;
|
||||
|
||||
let branch_writer = branch::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create branch writer")?;
|
||||
|
||||
let use_context = project_repository
|
||||
.project()
|
||||
.use_diff_context
|
||||
.unwrap_or(false);
|
||||
let context_lines = if use_context { 3_u32 } else { 0_u32 };
|
||||
|
||||
// try to update every branch
|
||||
let updated_vbranches = super::get_status_by_branch(gb_repository, project_repository)?
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(branch, _)| branch)
|
||||
.map(
|
||||
|mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
let branch_tree = repo.find_tree(branch.tree)?;
|
||||
|
||||
let branch_head_commit = repo.find_commit(branch.head).context(format!(
|
||||
"failed to find commit {} for branch {}",
|
||||
branch.head, branch.id
|
||||
))?;
|
||||
let branch_head_tree = branch_head_commit.tree().context(format!(
|
||||
"failed to find tree for commit {} for branch {}",
|
||||
branch.head, branch.id
|
||||
))?;
|
||||
|
||||
let result_integrated_detected =
|
||||
|mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
// branch head tree is the same as the new target tree.
|
||||
// meaning we can safely use the new target commit as the branch head.
|
||||
|
||||
branch.head = new_target_commit.id();
|
||||
|
||||
// it also means that the branch is fully integrated into the target.
|
||||
// disconnect it from the upstream
|
||||
branch.upstream = None;
|
||||
branch.upstream_head = None;
|
||||
|
||||
let non_commited_files = diff::trees(
|
||||
&project_repository.git_repository,
|
||||
&branch_head_tree,
|
||||
&branch_tree,
|
||||
context_lines,
|
||||
)?;
|
||||
if non_commited_files.is_empty() {
|
||||
// if there are no commited files, then the branch is fully merged
|
||||
// and we can delete it.
|
||||
branch_writer.delete(&branch)?;
|
||||
project_repository.delete_branch_reference(&branch)?;
|
||||
Ok(None)
|
||||
} else {
|
||||
branch_writer.write(&mut branch)?;
|
||||
Ok(Some(branch))
|
||||
}
|
||||
};
|
||||
|
||||
if branch_head_tree.id() == new_target_tree.id() {
|
||||
return result_integrated_detected(branch);
|
||||
}
|
||||
|
||||
// try to merge branch head with new target
|
||||
let mut branch_tree_merge_index = repo
|
||||
.merge_trees(&old_target_tree, &branch_tree, &new_target_tree)
|
||||
.context(format!("failed to merge trees for branch {}", branch.id))?;
|
||||
|
||||
if branch_tree_merge_index.has_conflicts() {
|
||||
// branch tree conflicts with new target, unapply branch for now. we'll handle it later, when user applies it back.
|
||||
branch.applied = false;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
let branch_merge_index_tree_oid = branch_tree_merge_index.write_tree_to(repo)?;
|
||||
|
||||
if branch_merge_index_tree_oid == new_target_tree.id() {
|
||||
return result_integrated_detected(branch);
|
||||
}
|
||||
|
||||
if branch.head == target.sha {
|
||||
// there are no commits on the branch, so we can just update the head to the new target and calculate the new tree
|
||||
branch.head = new_target_commit.id();
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
let mut branch_head_merge_index = repo
|
||||
.merge_trees(&old_target_tree, &branch_head_tree, &new_target_tree)
|
||||
.context(format!(
|
||||
"failed to merge head tree for branch {}",
|
||||
branch.id
|
||||
))?;
|
||||
|
||||
if branch_head_merge_index.has_conflicts() {
|
||||
// branch commits conflict with new target, make sure the branch is
|
||||
// unapplied. conflicts witll be dealt with when applying it back.
|
||||
branch.applied = false;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
// branch commits do not conflict with new target, so lets merge them
|
||||
let branch_head_merge_tree_oid = branch_head_merge_index
|
||||
.write_tree_to(repo)
|
||||
.context(format!(
|
||||
"failed to write head merge index for {}",
|
||||
branch.id
|
||||
))?;
|
||||
|
||||
let ok_with_force_push = project_repository.project().ok_with_force_push;
|
||||
|
||||
let result_merge = |mut branch: branch::Branch| -> Result<Option<branch::Branch>> {
|
||||
// branch was pushed to upstream, and user doesn't like force pushing.
|
||||
// create a merge commit to avoid the need of force pushing then.
|
||||
let branch_head_merge_tree = repo
|
||||
.find_tree(branch_head_merge_tree_oid)
|
||||
.context("failed to find tree")?;
|
||||
|
||||
let new_target_head = project_repository
|
||||
.commit(
|
||||
user,
|
||||
format!(
|
||||
"Merged {}/{} into {}",
|
||||
target.branch.remote(),
|
||||
target.branch.branch(),
|
||||
branch.name
|
||||
)
|
||||
.as_str(),
|
||||
&branch_head_merge_tree,
|
||||
&[&branch_head_commit, &new_target_commit],
|
||||
signing_key,
|
||||
)
|
||||
.context("failed to commit merge")?;
|
||||
|
||||
branch.head = new_target_head;
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
Ok(Some(branch))
|
||||
};
|
||||
|
||||
if branch.upstream.is_some() && !ok_with_force_push {
|
||||
return result_merge(branch);
|
||||
}
|
||||
|
||||
// branch was not pushed to upstream yet. attempt a rebase,
|
||||
let (_, committer) = project_repository.git_signatures(user)?;
|
||||
let mut rebase_options = git2::RebaseOptions::new();
|
||||
rebase_options.quiet(true);
|
||||
rebase_options.inmemory(true);
|
||||
let mut rebase = repo
|
||||
.rebase(
|
||||
Some(branch.head),
|
||||
Some(new_target_commit.id()),
|
||||
None,
|
||||
Some(&mut rebase_options),
|
||||
)
|
||||
.context("failed to rebase")?;
|
||||
|
||||
let mut rebase_success = true;
|
||||
// check to see if these commits have already been pushed
|
||||
let mut last_rebase_head = branch.head;
|
||||
while rebase.next().is_some() {
|
||||
let index = rebase
|
||||
.inmemory_index()
|
||||
.context("failed to get inmemory index")?;
|
||||
if index.has_conflicts() {
|
||||
rebase_success = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(commit_id) = rebase.commit(None, &committer.clone().into(), None) {
|
||||
last_rebase_head = commit_id.into();
|
||||
} else {
|
||||
rebase_success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if rebase_success {
|
||||
// rebase worked out, rewrite the branch head
|
||||
rebase.finish(None).context("failed to finish rebase")?;
|
||||
branch.head = last_rebase_head;
|
||||
branch.tree = branch_merge_index_tree_oid;
|
||||
branch_writer.write(&mut branch)?;
|
||||
return Ok(Some(branch));
|
||||
}
|
||||
|
||||
// rebase failed, do a merge commit
|
||||
rebase.abort().context("failed to abort rebase")?;
|
||||
|
||||
result_merge(branch)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// ok, now all the problematic branches have been unapplied
|
||||
// now we calculate and checkout new tree for the working directory
|
||||
|
||||
let final_tree = updated_vbranches
|
||||
.iter()
|
||||
.filter(|branch| branch.applied)
|
||||
.fold(new_target_commit.tree(), |final_tree, branch| {
|
||||
let final_tree = final_tree?;
|
||||
let branch_tree = repo.find_tree(branch.tree)?;
|
||||
let mut merge_result = repo.merge_trees(&new_target_tree, &final_tree, &branch_tree)?;
|
||||
let final_tree_oid = merge_result.write_tree_to(repo)?;
|
||||
repo.find_tree(final_tree_oid)
|
||||
})
|
||||
.context("failed to calculate final tree")?;
|
||||
|
||||
repo.checkout_tree(&final_tree).force().checkout().context(
|
||||
"failed to checkout index, this should not have happened, we should have already detected this",
|
||||
)?;
|
||||
|
||||
// write new target oid
|
||||
let target_writer = target::Writer::new(gb_repository, project_repository.project().gb_dir())
|
||||
.context("failed to create target writer")?;
|
||||
target_writer.write_default(&target::Target {
|
||||
sha: new_target_commit.id(),
|
||||
..target
|
||||
})?;
|
||||
|
||||
super::integration::update_gitbutler_integration(gb_repository, project_repository)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn target_to_base_branch(
|
||||
project_repository: &project_repository::Repository,
|
||||
target: &target::Target,
|
||||
) -> Result<super::BaseBranch> {
|
||||
let repo = &project_repository.git_repository;
|
||||
let branch = repo.find_branch(&target.branch.clone().into())?;
|
||||
let commit = branch.peel_to_commit()?;
|
||||
let oid = commit.id();
|
||||
|
||||
// gather a list of commits between oid and target.sha
|
||||
let upstream_commits = project_repository
|
||||
.log(oid, project_repository::LogUntil::Commit(target.sha))
|
||||
.context("failed to get upstream commits")?
|
||||
.iter()
|
||||
.map(super::commit_to_remote_commit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// get some recent commits
|
||||
let recent_commits = project_repository
|
||||
.log(target.sha, LogUntil::Take(20))
|
||||
.context("failed to get recent commits")?
|
||||
.iter()
|
||||
.map(super::commit_to_remote_commit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let base = super::BaseBranch {
|
||||
branch_name: format!("{}/{}", target.branch.remote(), target.branch.branch()),
|
||||
remote_name: target.branch.remote().to_string(),
|
||||
remote_url: target.remote_url.clone(),
|
||||
base_sha: target.sha,
|
||||
current_sha: oid,
|
||||
behind: upstream_commits.len(),
|
||||
upstream_commits,
|
||||
recent_commits,
|
||||
last_fetched_ms: project_repository
|
||||
.project()
|
||||
.project_data_last_fetch
|
||||
.as_ref()
|
||||
.map(FetchResult::timestamp)
|
||||
.copied()
|
||||
.map(|t| t.duration_since(time::UNIX_EPOCH).unwrap().as_millis()),
|
||||
};
|
||||
Ok(base)
|
||||
}
|
237
src/virtual_branches/branch.rs
Normal file
237
src/virtual_branches/branch.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
178
src/virtual_branches/branch/file_ownership.rs
Normal file
178
src/virtual_branches/branch/file_ownership.rs
Normal 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(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
169
src/virtual_branches/branch/hunk.rs
Normal file
169
src/virtual_branches/branch/hunk.rs
Normal 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))
|
||||
}
|
||||
}
|
183
src/virtual_branches/branch/ownership.rs
Normal file
183
src/virtual_branches/branch/ownership.rs
Normal 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)
|
||||
}
|
19
src/virtual_branches/branch/reader.rs
Normal file
19
src/virtual_branches/branch/reader.rs
Normal 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)))
|
||||
}
|
||||
}
|
160
src/virtual_branches/branch/writer.rs
Normal file
160
src/virtual_branches/branch/writer.rs
Normal 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(())
|
||||
}
|
||||
}
|
124
src/virtual_branches/context.rs
Normal file
124
src/virtual_branches/context.rs
Normal 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
|
||||
}
|
1112
src/virtual_branches/controller.rs
Normal file
1112
src/virtual_branches/controller.rs
Normal file
File diff suppressed because it is too large
Load Diff
837
src/virtual_branches/errors.rs
Normal file
837
src/virtual_branches/errors.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
96
src/virtual_branches/files.rs
Normal file
96
src/virtual_branches/files.rs
Normal 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)
|
||||
}
|
351
src/virtual_branches/integration.rs
Normal file
351
src/virtual_branches/integration.rs
Normal 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
Loading…
Reference in New Issue
Block a user