chore: refactor project file change handler to simplify code and remove unnecessary dependencies

This commit is contained in:
Kiril Videlov 2023-11-16 14:22:00 +01:00 committed by Kiril Videlov
parent 3bd4f26f09
commit 606fbf8384
5 changed files with 957 additions and 917 deletions

View File

@ -32,6 +32,7 @@ pub enum Event {
Analytics(analytics::Event),
VirtualBranch(ProjectId),
SessionProcessing(ProjectId, path::PathBuf),
}
impl Event {
@ -51,6 +52,7 @@ impl Event {
| Event::SessionFile((project_id, _, _, _))
| Event::SessionDelta((project_id, _, _, _))
| Event::VirtualBranch(project_id)
| Event::SessionProcessing(project_id, _)
| Event::PushGitbutlerData(project_id)
| Event::PushProjectToGitbutler(project_id) => project_id,
}
@ -92,6 +94,9 @@ impl Display for Event {
)
}
Event::VirtualBranch(pid) => write!(f, "VirtualBranch({})", pid),
Event::SessionProcessing(project_id, path) => {
write!(f, "SessionProcessing({}, {})", project_id, path.display())
}
Event::PushGitbutlerData(pid) => write!(f, "PushGitbutlerData({})", pid),
Event::PushProjectToGitbutler(pid) => write!(f, "PushProjectToGitbutler({})", pid),
Event::IndexAll(pid) => write!(f, "IndexAll({})", pid),

View File

@ -176,8 +176,7 @@ mod test {
fn create_new_session_via_new_file(project: &projects::Project, suite: &Suite) {
fs::write(project.path.join("test.txt"), "test").unwrap();
let file_change_listener =
handlers::project_file_change::Handler::from(&suite.local_app_data);
let file_change_listener = handlers::project_file_change::Handler::new();
file_change_listener
.handle("test.txt", &project.id)
.unwrap();

View File

@ -7,6 +7,7 @@ mod index_handler;
mod project_file_change;
mod push_gitbutler_data;
mod push_project_to_gitbutler;
mod session_handler;
mod tick_handler;
mod vbranch_handler;
@ -33,6 +34,7 @@ pub struct Handler {
index_handler: index_handler::Handler,
push_project_to_gitbutler: push_project_to_gitbutler::Handler,
virtual_branch_handler: vbranch_handler::Handler,
session_processing_handler: session_handler::Handler,
events_sender: app_events::Sender,
}
@ -43,7 +45,7 @@ impl TryFrom<&AppHandle> for Handler {
fn try_from(value: &AppHandle) -> Result<Self, Self::Error> {
Ok(Self {
events_sender: app_events::Sender::from(value),
project_file_handler: project_file_change::Handler::try_from(value)?,
project_file_handler: project_file_change::Handler::new(),
tick_handler: tick_handler::Handler::try_from(value)?,
git_file_change_handler: git_file_change::Handler::try_from(value)?,
index_handler: index_handler::Handler::try_from(value)?,
@ -54,6 +56,7 @@ impl TryFrom<&AppHandle> for Handler {
analytics_handler: analytics_handler::Handler::from(value),
push_project_to_gitbutler: push_project_to_gitbutler::Handler::try_from(value)?,
virtual_branch_handler: vbranch_handler::Handler::try_from(value)?,
session_processing_handler: session_handler::Handler::try_from(value)?,
})
}
}
@ -136,6 +139,14 @@ impl Handler {
.handle(project_id)
.context("failed to handle virtual branch event"),
events::Event::SessionProcessing(project_id, path) => self
.session_processing_handler
.handle(path, project_id)
.context(format!(
"failed to handle session processing event: {:?}",
path.display()
)),
events::Event::Emit(event) => {
self.events_sender
.send(event)

View File

@ -1,79 +1,15 @@
use std::{path, vec};
use anyhow::{Context, Result};
use tauri::AppHandle;
use crate::{
deltas, gb_repository,
paths::DataDir,
project_repository,
projects::{self, ProjectId},
reader::{self, Reader},
sessions, users,
};
use crate::projects::ProjectId;
use anyhow::Result;
use std::vec;
use super::events;
#[derive(Clone)]
pub struct Handler {
local_data_dir: DataDir,
projects: projects::Controller,
users: users::Controller,
}
impl From<&DataDir> for Handler {
fn from(value: &DataDir) -> Self {
Self {
local_data_dir: value.clone(),
projects: projects::Controller::from(value),
users: users::Controller::from(value),
}
}
}
impl TryFrom<&AppHandle> for Handler {
type Error = anyhow::Error;
fn try_from(value: &AppHandle) -> Result<Self, Self::Error> {
Ok(Self {
local_data_dir: DataDir::try_from(value)?,
projects: projects::Controller::try_from(value)?,
users: users::Controller::from(value),
})
}
}
#[derive(Clone, Default)]
pub struct Handler {}
impl Handler {
// Returns Some(file_content) or None if the file is ignored.
fn get_current_file(
project_repository: &project_repository::Repository,
path: &std::path::Path,
) -> Result<reader::Content, reader::Error> {
if project_repository.is_path_ignored(path).unwrap_or(false) {
return Err(reader::Error::NotFound);
}
let reader = project_repository.get_wd_reader();
reader.read(path)
}
// returns deltas for the file that are already part of the current session (if any)
fn get_current_deltas(
gb_repo: &gb_repository::Repository,
path: &path::Path,
) -> Result<Option<Vec<deltas::Delta>>> {
if let Some(current_session) = gb_repo.get_current_session()? {
let session_reader = sessions::Reader::open(gb_repo, &current_session)
.context("failed to get session reader")?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader
.read_file(path)
.context("failed to get file deltas")?;
Ok(deltas)
} else {
Ok(None)
}
pub fn new() -> Self {
Default::default()
}
pub fn handle<P: AsRef<std::path::Path>>(
@ -81,848 +17,11 @@ impl Handler {
path: P,
project_id: &ProjectId,
) -> Result<Vec<events::Event>> {
let project = self
.projects
.get(project_id)
.context("failed to get project")?;
let project_repository = project_repository::Repository::open(&project)
.with_context(|| "failed to open project repository for project")?;
let user = self.users.get_user().context("failed to get user")?;
let gb_repository = gb_repository::Repository::open(
&self.local_data_dir,
&project_repository,
user.as_ref(),
)
.context("failed to open gb repository")?;
// If current session's branch is not the same as the project's head, flush it first.
if let Some(session) = gb_repository
.get_current_session()
.context("failed to get current session")?
{
let project_head = project_repository
.get_head()
.context("failed to get head")?;
if session.meta.branch != project_head.name().map(ToString::to_string) {
gb_repository
.flush_session(&project_repository, &session, user.as_ref())
.context(format!("failed to flush session {}", session.id))?;
}
}
let path = path.as_ref();
let current_wd_file_content = match Self::get_current_file(&project_repository, path) {
Ok(content) => Some(content),
Err(reader::Error::NotFound) => None,
Err(err) => Err(err).context("failed to get file content")?,
};
let current_session = gb_repository
.get_or_create_current_session()
.context("failed to get or create current session")?;
let current_session_reader = sessions::Reader::open(&gb_repository, &current_session)
.context("failed to get session reader")?;
let latest_file_content = match current_session_reader.file(path) {
Ok(content) => Some(content),
Err(reader::Error::NotFound) => None,
Err(err) => Err(err).context("failed to get file content")?,
};
let current_deltas = Self::get_current_deltas(&gb_repository, path)
.with_context(|| "failed to get current deltas")?;
let mut text_doc = deltas::Document::new(
latest_file_content.as_ref(),
current_deltas.unwrap_or_default(),
)?;
let new_delta = text_doc
.update(current_wd_file_content.as_ref())
.context("failed to calculate new deltas")?;
if let Some(new_delta) = new_delta {
let deltas = text_doc.get_deltas();
let writer = deltas::Writer::new(&gb_repository);
writer
.write(path, &deltas)
.with_context(|| "failed to write deltas")?;
match &current_wd_file_content {
Some(reader::Content::UTF8(text)) => writer.write_wd_file(path, text),
Some(_) => writer.write_wd_file(path, ""),
None => writer.remove_wd_file(path),
}?;
Ok(vec![
events::Event::SessionFile((
*project_id,
current_session.id,
path.to_path_buf(),
latest_file_content,
)),
events::Event::Session(*project_id, current_session.clone()),
events::Event::SessionDelta((
*project_id,
current_session.id,
path.to_path_buf(),
new_delta.clone(),
)),
// TODO: extract all the sessions stuff in a separate event handler
events::Event::VirtualBranch(*project_id),
])
} else {
tracing::debug!(%project_id, path = %path.display(), "no new deltas, ignoring");
Ok(vec![])
}
}
}
#[cfg(test)]
mod test {
use std::{
collections::HashMap,
sync::atomic::{AtomicUsize, Ordering},
};
use once_cell::sync::Lazy;
use crate::{
deltas, sessions,
test_utils::{self, Case, Suite},
virtual_branches::{self, branch},
};
use self::branch::BranchId;
use super::*;
static TEST_TARGET_INDEX: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
fn test_target() -> virtual_branches::target::Target {
virtual_branches::target::Target {
branch: format!(
"refs/remotes/remote name {}/branch name {}",
TEST_TARGET_INDEX.load(Ordering::Relaxed),
TEST_TARGET_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
remote_url: format!("remote url {}", TEST_TARGET_INDEX.load(Ordering::Relaxed)),
sha: format!(
"0123456789abcdef0123456789abcdef0123456{}",
TEST_TARGET_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
}
}
static TEST_INDEX: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
fn test_branch() -> virtual_branches::branch::Branch {
TEST_INDEX.fetch_add(1, Ordering::Relaxed);
virtual_branches::branch::Branch {
id: BranchId::generate(),
name: format!("branch_name_{}", TEST_INDEX.load(Ordering::Relaxed)),
notes: format!("branch_notes_{}", TEST_INDEX.load(Ordering::Relaxed)),
applied: true,
upstream: Some(
format!(
"refs/remotes/origin/upstream_{}",
TEST_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
),
upstream_head: None,
created_timestamp_ms: TEST_INDEX.load(Ordering::Relaxed) as u128,
updated_timestamp_ms: (TEST_INDEX.load(Ordering::Relaxed) + 100) as u128,
head: format!(
"0123456789abcdef0123456789abcdef0123456{}",
TEST_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
tree: format!(
"0123456789abcdef0123456789abcdef012345{}",
TEST_INDEX.load(Ordering::Relaxed) + 10
)
.parse()
.unwrap(),
ownership: branch::Ownership::default(),
order: TEST_INDEX.load(Ordering::Relaxed),
}
}
#[test]
fn test_register_existing_commited_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case_with_files(HashMap::from([(path::PathBuf::from("test.txt"), "test")]));
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((4, "2".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test2"
);
Ok(())
}
#[test]
fn test_register_must_init_current_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
assert!(gb_repository.get_current_session()?.is_some());
Ok(())
}
#[test]
fn test_register_must_not_override_current_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session1 = gb_repository.get_current_session()?.unwrap();
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let session2 = gb_repository.get_current_session()?.unwrap();
assert_eq!(session1.id, session2.id);
Ok(())
}
#[test]
fn test_register_binfile() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(
project.path.join("test.bin"),
[0, 159, 146, 150, 159, 146, 150],
)?;
listener.handle("test.bin", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.bin")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 0);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.bin"))?,
""
);
Ok(())
}
#[test]
fn test_register_empty_new_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 0);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
""
);
Ok(())
}
#[test]
fn test_register_new_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
Ok(())
}
#[test]
fn test_register_no_changes_saved_thgoughout_flushes() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
// file change, wd and deltas are written
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
// make two more sessions.
gb_repository.flush(&project_repository, None)?;
gb_repository.get_or_create_current_session()?;
gb_repository.flush(&project_repository, None)?;
// after some sessions, files from the first change are still there.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None)?;
assert_eq!(files.len(), 1);
Ok(())
}
#[test]
fn test_register_new_file_twice() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 2);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(deltas[1].operations.len(), 1);
assert_eq!(
deltas[1].operations[0],
deltas::Operation::Insert((4, "2".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test2"
);
Ok(())
}
#[test]
fn test_register_file_deleted() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
{
// write file
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
}
{
// current session must have the deltas, but not the file (it didn't exist)
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
let files = session_reader.files(None).unwrap();
assert!(files.is_empty());
}
gb_repository.flush(&project_repository, None)?;
{
// file should be available in the next session, but not deltas just yet.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(
files[std::path::Path::new("test.txt")],
reader::Content::UTF8("test".to_string())
);
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read(None)?;
assert!(deltas.is_empty());
// removing the file
std::fs::remove_file(project.path.join("test.txt"))?;
listener.handle("test.txt", &project.id)?;
// deltas are recorded
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(deltas[0].operations[0], deltas::Operation::Delete((0, 4)),);
}
gb_repository.flush(&project_repository, None)?;
{
// since file was deleted in the previous session, it should not exist in the new one.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None).unwrap();
assert!(files.is_empty());
}
Ok(())
}
#[test]
fn test_flow_with_commits() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
test_utils::commit_all(&project_repository.git_repository);
listener.handle(relative_file_path, &project.id)?;
assert!(gb_repository.flush(&project_repository, None)?.is_some());
}
// get all the created sessions
let mut sessions: Vec<sessions::Session> = gb_repository
.get_sessions_iterator()?
.map(Result::unwrap)
.collect();
assert_eq!(sessions.len(), size);
// verify sessions order is correct
let mut last_start = sessions[0].meta.start_timestamp_ms;
let mut last_end = sessions[0].meta.start_timestamp_ms;
sessions[1..].iter().for_each(|session| {
assert!(session.meta.start_timestamp_ms < last_start);
assert!(session.meta.last_timestamp_ms < last_end);
last_start = session.meta.start_timestamp_ms;
last_end = session.meta.last_timestamp_ms;
});
sessions.reverse();
// try to reconstruct file state from operations for every session slice
for i in 0..sessions.len() {
let sessions_slice = &mut sessions[i..];
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
for session in &mut *sessions_slice {
let session_reader = sessions::Reader::open(&gb_repository, session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
}
let reader =
sessions::Reader::open(&gb_repository, sessions_slice.first().unwrap()).unwrap();
let files = reader.files(None).unwrap();
if i == 0 {
assert_eq!(files.len(), 0);
} else {
assert_eq!(files.len(), 1);
}
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
}
Ok(())
}
#[test]
fn test_flow_no_commits() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
listener.handle(relative_file_path, &project.id)?;
assert!(gb_repository.flush(&project_repository, None)?.is_some());
}
// get all the created sessions
let mut sessions: Vec<sessions::Session> = gb_repository
.get_sessions_iterator()?
.map(Result::unwrap)
.collect();
assert_eq!(sessions.len(), size);
// verify sessions order is correct
let mut last_start = sessions[0].meta.start_timestamp_ms;
let mut last_end = sessions[0].meta.start_timestamp_ms;
sessions[1..].iter().for_each(|session| {
assert!(session.meta.start_timestamp_ms < last_start);
assert!(session.meta.last_timestamp_ms < last_end);
last_start = session.meta.start_timestamp_ms;
last_end = session.meta.last_timestamp_ms;
});
sessions.reverse();
// try to reconstruct file state from operations for every session slice
for i in 0..sessions.len() {
let sessions_slice = &mut sessions[i..];
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
for session in &mut *sessions_slice {
let session_reader = sessions::Reader::open(&gb_repository, session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
}
let reader =
sessions::Reader::open(&gb_repository, sessions_slice.first().unwrap()).unwrap();
let files = reader.files(None).unwrap();
if i == 0 {
assert_eq!(files.len(), 0);
} else {
assert_eq!(files.len(), 1);
}
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
}
Ok(())
}
#[test]
fn test_flow_signle_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10_i32;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1_i32..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
listener.handle(relative_file_path, &project.id)?;
}
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
let reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let files = reader.files(None).unwrap();
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
Ok(())
}
#[test]
fn should_persist_branches_targets_state_between_sessions() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case_with_files(HashMap::from([(
path::PathBuf::from("test.txt"),
"hello world",
)]));
let listener = Handler::from(&suite.local_app_data);
let branch_writer = virtual_branches::branch::Writer::new(&gb_repository);
let target_writer = virtual_branches::target::Writer::new(&gb_repository);
let default_target = test_target();
target_writer.write_default(&default_target)?;
let vbranch0 = test_branch();
branch_writer.write(&vbranch0)?;
let vbranch1 = test_branch();
let vbranch1_target = test_target();
branch_writer.write(&vbranch1)?;
target_writer.write(&vbranch1.id, &vbranch1_target)?;
std::fs::write(project.path.join("test.txt"), "hello world!").unwrap();
listener.handle("test.txt", &project.id)?;
let flushed_session = gb_repository.flush(&project_repository, None).unwrap();
// create a new session
let session = gb_repository.get_or_create_current_session().unwrap();
assert_ne!(session.id, flushed_session.unwrap().id);
// ensure that the virtual branch is still there and selected
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let branches = virtual_branches::Iterator::new(&session_reader)
.unwrap()
.collect::<Result<Vec<virtual_branches::Branch>, crate::reader::Error>>()
.unwrap()
.into_iter()
.collect::<Vec<virtual_branches::Branch>>();
assert_eq!(branches.len(), 2);
let branch_ids = branches.iter().map(|b| b.id).collect::<Vec<_>>();
assert!(branch_ids.contains(&vbranch0.id));
assert!(branch_ids.contains(&vbranch1.id));
let target_reader = virtual_branches::target::Reader::new(&session_reader);
assert_eq!(target_reader.read_default().unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch0.id).unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch1.id).unwrap(), vbranch1_target);
Ok(())
}
#[test]
fn should_restore_branches_targets_state_from_head_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case_with_files(HashMap::from([(
path::PathBuf::from("test.txt"),
"hello world",
)]));
let listener = Handler::from(&suite.local_app_data);
let branch_writer = virtual_branches::branch::Writer::new(&gb_repository);
let target_writer = virtual_branches::target::Writer::new(&gb_repository);
let default_target = test_target();
target_writer.write_default(&default_target)?;
let vbranch0 = test_branch();
branch_writer.write(&vbranch0)?;
let vbranch1 = test_branch();
let vbranch1_target = test_target();
branch_writer.write(&vbranch1)?;
target_writer.write(&vbranch1.id, &vbranch1_target)?;
std::fs::write(project.path.join("test.txt"), "hello world!").unwrap();
listener.handle("test.txt", &project.id).unwrap();
let flushed_session = gb_repository.flush(&project_repository, None).unwrap();
// hard delete branches state from disk
std::fs::remove_dir_all(gb_repository.root()).unwrap();
// create a new session
let session = gb_repository.get_or_create_current_session().unwrap();
assert_ne!(session.id, flushed_session.unwrap().id);
// ensure that the virtual branch is still there and selected
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let branches = virtual_branches::Iterator::new(&session_reader)
.unwrap()
.collect::<Result<Vec<virtual_branches::Branch>, crate::reader::Error>>()
.unwrap()
.into_iter()
.collect::<Vec<virtual_branches::Branch>>();
assert_eq!(branches.len(), 2);
let branch_ids = branches.iter().map(|b| b.id).collect::<Vec<_>>();
assert!(branch_ids.contains(&vbranch0.id));
assert!(branch_ids.contains(&vbranch1.id));
let target_reader = virtual_branches::target::Reader::new(&session_reader);
assert_eq!(target_reader.read_default().unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch0.id).unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch1.id).unwrap(), vbranch1_target);
Ok(())
let path = path.as_ref().to_path_buf();
Ok(vec![
events::Event::SessionProcessing(project_id.clone(), path),
// TODO: throttle this event to max 1 per 30ms
events::Event::VirtualBranch(project_id.clone()),
])
}
}

View File

@ -0,0 +1,926 @@
use std::{path, vec};
use anyhow::{Context, Result};
use tauri::AppHandle;
use crate::{
deltas, gb_repository,
paths::DataDir,
project_repository,
projects::{self, ProjectId},
reader::{self, Reader},
sessions, users,
};
use super::events;
#[derive(Clone)]
pub struct Handler {
local_data_dir: DataDir,
projects: projects::Controller,
users: users::Controller,
}
impl From<&DataDir> for Handler {
fn from(value: &DataDir) -> Self {
Self {
local_data_dir: value.clone(),
projects: projects::Controller::from(value),
users: users::Controller::from(value),
}
}
}
impl TryFrom<&AppHandle> for Handler {
type Error = anyhow::Error;
fn try_from(value: &AppHandle) -> Result<Self, Self::Error> {
Ok(Self {
local_data_dir: DataDir::try_from(value)?,
projects: projects::Controller::try_from(value)?,
users: users::Controller::from(value),
})
}
}
impl Handler {
// Returns Some(file_content) or None if the file is ignored.
fn get_current_file(
project_repository: &project_repository::Repository,
path: &std::path::Path,
) -> Result<reader::Content, reader::Error> {
if project_repository.is_path_ignored(path).unwrap_or(false) {
return Err(reader::Error::NotFound);
}
let reader = project_repository.get_wd_reader();
reader.read(path)
}
// returns deltas for the file that are already part of the current session (if any)
fn get_current_deltas(
gb_repo: &gb_repository::Repository,
path: &path::Path,
) -> Result<Option<Vec<deltas::Delta>>> {
if let Some(current_session) = gb_repo.get_current_session()? {
let session_reader = sessions::Reader::open(gb_repo, &current_session)
.context("failed to get session reader")?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader
.read_file(path)
.context("failed to get file deltas")?;
Ok(deltas)
} else {
Ok(None)
}
}
pub fn handle<P: AsRef<std::path::Path>>(
&self,
path: P,
project_id: &ProjectId,
) -> Result<Vec<events::Event>> {
let project = self
.projects
.get(project_id)
.context("failed to get project")?;
let project_repository = project_repository::Repository::open(&project)
.with_context(|| "failed to open project repository for project")?;
let user = self.users.get_user().context("failed to get user")?;
let gb_repository = gb_repository::Repository::open(
&self.local_data_dir,
&project_repository,
user.as_ref(),
)
.context("failed to open gb repository")?;
// If current session's branch is not the same as the project's head, flush it first.
if let Some(session) = gb_repository
.get_current_session()
.context("failed to get current session")?
{
let project_head = project_repository
.get_head()
.context("failed to get head")?;
if session.meta.branch != project_head.name().map(ToString::to_string) {
gb_repository
.flush_session(&project_repository, &session, user.as_ref())
.context(format!("failed to flush session {}", session.id))?;
}
}
let path = path.as_ref();
let current_wd_file_content = match Self::get_current_file(&project_repository, path) {
Ok(content) => Some(content),
Err(reader::Error::NotFound) => None,
Err(err) => Err(err).context("failed to get file content")?,
};
let current_session = gb_repository
.get_or_create_current_session()
.context("failed to get or create current session")?;
let current_session_reader = sessions::Reader::open(&gb_repository, &current_session)
.context("failed to get session reader")?;
let latest_file_content = match current_session_reader.file(path) {
Ok(content) => Some(content),
Err(reader::Error::NotFound) => None,
Err(err) => Err(err).context("failed to get file content")?,
};
let current_deltas = Self::get_current_deltas(&gb_repository, path)
.with_context(|| "failed to get current deltas")?;
let mut text_doc = deltas::Document::new(
latest_file_content.as_ref(),
current_deltas.unwrap_or_default(),
)?;
let new_delta = text_doc
.update(current_wd_file_content.as_ref())
.context("failed to calculate new deltas")?;
if let Some(new_delta) = new_delta {
let deltas = text_doc.get_deltas();
let writer = deltas::Writer::new(&gb_repository);
writer
.write(path, &deltas)
.with_context(|| "failed to write deltas")?;
match &current_wd_file_content {
Some(reader::Content::UTF8(text)) => writer.write_wd_file(path, text),
Some(_) => writer.write_wd_file(path, ""),
None => writer.remove_wd_file(path),
}?;
Ok(vec![
events::Event::SessionFile((
*project_id,
current_session.id,
path.to_path_buf(),
latest_file_content,
)),
events::Event::Session(*project_id, current_session.clone()),
events::Event::SessionDelta((
*project_id,
current_session.id,
path.to_path_buf(),
new_delta.clone(),
)),
])
} else {
tracing::debug!(%project_id, path = %path.display(), "no new deltas, ignoring");
Ok(vec![])
}
}
}
#[cfg(test)]
mod test {
use std::{
collections::HashMap,
sync::atomic::{AtomicUsize, Ordering},
};
use once_cell::sync::Lazy;
use crate::{
deltas, sessions,
test_utils::{self, Case, Suite},
virtual_branches::{self, branch},
};
use self::branch::BranchId;
use super::*;
static TEST_TARGET_INDEX: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
fn test_target() -> virtual_branches::target::Target {
virtual_branches::target::Target {
branch: format!(
"refs/remotes/remote name {}/branch name {}",
TEST_TARGET_INDEX.load(Ordering::Relaxed),
TEST_TARGET_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
remote_url: format!("remote url {}", TEST_TARGET_INDEX.load(Ordering::Relaxed)),
sha: format!(
"0123456789abcdef0123456789abcdef0123456{}",
TEST_TARGET_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
}
}
static TEST_INDEX: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
fn test_branch() -> virtual_branches::branch::Branch {
TEST_INDEX.fetch_add(1, Ordering::Relaxed);
virtual_branches::branch::Branch {
id: BranchId::generate(),
name: format!("branch_name_{}", TEST_INDEX.load(Ordering::Relaxed)),
notes: format!("branch_notes_{}", TEST_INDEX.load(Ordering::Relaxed)),
applied: true,
upstream: Some(
format!(
"refs/remotes/origin/upstream_{}",
TEST_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
),
upstream_head: None,
created_timestamp_ms: TEST_INDEX.load(Ordering::Relaxed) as u128,
updated_timestamp_ms: (TEST_INDEX.load(Ordering::Relaxed) + 100) as u128,
head: format!(
"0123456789abcdef0123456789abcdef0123456{}",
TEST_INDEX.load(Ordering::Relaxed)
)
.parse()
.unwrap(),
tree: format!(
"0123456789abcdef0123456789abcdef012345{}",
TEST_INDEX.load(Ordering::Relaxed) + 10
)
.parse()
.unwrap(),
ownership: branch::Ownership::default(),
order: TEST_INDEX.load(Ordering::Relaxed),
}
}
#[test]
fn test_register_existing_commited_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case_with_files(HashMap::from([(path::PathBuf::from("test.txt"), "test")]));
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((4, "2".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test2"
);
Ok(())
}
#[test]
fn test_register_must_init_current_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
assert!(gb_repository.get_current_session()?.is_some());
Ok(())
}
#[test]
fn test_register_must_not_override_current_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session1 = gb_repository.get_current_session()?.unwrap();
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let session2 = gb_repository.get_current_session()?.unwrap();
assert_eq!(session1.id, session2.id);
Ok(())
}
#[test]
fn test_register_binfile() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(
project.path.join("test.bin"),
[0, 159, 146, 150, 159, 146, 150],
)?;
listener.handle("test.bin", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.bin")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 0);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.bin"))?,
""
);
Ok(())
}
#[test]
fn test_register_empty_new_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 0);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
""
);
Ok(())
}
#[test]
fn test_register_new_file() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
Ok(())
}
#[test]
fn test_register_no_changes_saved_thgoughout_flushes() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
// file change, wd and deltas are written
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
// make two more sessions.
gb_repository.flush(&project_repository, None)?;
gb_repository.get_or_create_current_session()?;
gb_repository.flush(&project_repository, None)?;
// after some sessions, files from the first change are still there.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None)?;
assert_eq!(files.len(), 1);
Ok(())
}
#[test]
fn test_register_new_file_twice() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
std::fs::write(project.path.join("test.txt"), "test2")?;
listener.handle("test.txt", &project.id)?;
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 2);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(deltas[1].operations.len(), 1);
assert_eq!(
deltas[1].operations[0],
deltas::Operation::Insert((4, "2".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test2"
);
Ok(())
}
#[test]
fn test_register_file_deleted() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
{
// write file
std::fs::write(project.path.join("test.txt"), "test")?;
listener.handle("test.txt", &project.id)?;
}
{
// current session must have the deltas, but not the file (it didn't exist)
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(
deltas[0].operations[0],
deltas::Operation::Insert((0, "test".to_string())),
);
assert_eq!(
std::fs::read_to_string(gb_repository.session_wd_path().join("test.txt"))?,
"test"
);
let files = session_reader.files(None).unwrap();
assert!(files.is_empty());
}
gb_repository.flush(&project_repository, None)?;
{
// file should be available in the next session, but not deltas just yet.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(
files[std::path::Path::new("test.txt")],
reader::Content::UTF8("test".to_string())
);
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas = deltas_reader.read(None)?;
assert!(deltas.is_empty());
// removing the file
std::fs::remove_file(project.path.join("test.txt"))?;
listener.handle("test.txt", &project.id)?;
// deltas are recorded
let deltas = deltas_reader.read_file("test.txt")?.unwrap();
assert_eq!(deltas.len(), 1);
assert_eq!(deltas[0].operations.len(), 1);
assert_eq!(deltas[0].operations[0], deltas::Operation::Delete((0, 4)),);
}
gb_repository.flush(&project_repository, None)?;
{
// since file was deleted in the previous session, it should not exist in the new one.
let session = gb_repository.get_or_create_current_session()?;
let session_reader = sessions::Reader::open(&gb_repository, &session)?;
let files = session_reader.files(None).unwrap();
assert!(files.is_empty());
}
Ok(())
}
#[test]
fn test_flow_with_commits() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
test_utils::commit_all(&project_repository.git_repository);
listener.handle(relative_file_path, &project.id)?;
assert!(gb_repository.flush(&project_repository, None)?.is_some());
}
// get all the created sessions
let mut sessions: Vec<sessions::Session> = gb_repository
.get_sessions_iterator()?
.map(Result::unwrap)
.collect();
assert_eq!(sessions.len(), size);
// verify sessions order is correct
let mut last_start = sessions[0].meta.start_timestamp_ms;
let mut last_end = sessions[0].meta.start_timestamp_ms;
sessions[1..].iter().for_each(|session| {
assert!(session.meta.start_timestamp_ms < last_start);
assert!(session.meta.last_timestamp_ms < last_end);
last_start = session.meta.start_timestamp_ms;
last_end = session.meta.last_timestamp_ms;
});
sessions.reverse();
// try to reconstruct file state from operations for every session slice
for i in 0..sessions.len() {
let sessions_slice = &mut sessions[i..];
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
for session in &mut *sessions_slice {
let session_reader = sessions::Reader::open(&gb_repository, session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
}
let reader =
sessions::Reader::open(&gb_repository, sessions_slice.first().unwrap()).unwrap();
let files = reader.files(None).unwrap();
if i == 0 {
assert_eq!(files.len(), 0);
} else {
assert_eq!(files.len(), 1);
}
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
}
Ok(())
}
#[test]
fn test_flow_no_commits() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
listener.handle(relative_file_path, &project.id)?;
assert!(gb_repository.flush(&project_repository, None)?.is_some());
}
// get all the created sessions
let mut sessions: Vec<sessions::Session> = gb_repository
.get_sessions_iterator()?
.map(Result::unwrap)
.collect();
assert_eq!(sessions.len(), size);
// verify sessions order is correct
let mut last_start = sessions[0].meta.start_timestamp_ms;
let mut last_end = sessions[0].meta.start_timestamp_ms;
sessions[1..].iter().for_each(|session| {
assert!(session.meta.start_timestamp_ms < last_start);
assert!(session.meta.last_timestamp_ms < last_end);
last_start = session.meta.start_timestamp_ms;
last_end = session.meta.last_timestamp_ms;
});
sessions.reverse();
// try to reconstruct file state from operations for every session slice
for i in 0..sessions.len() {
let sessions_slice = &mut sessions[i..];
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
for session in &mut *sessions_slice {
let session_reader = sessions::Reader::open(&gb_repository, session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
}
let reader =
sessions::Reader::open(&gb_repository, sessions_slice.first().unwrap()).unwrap();
let files = reader.files(None).unwrap();
if i == 0 {
assert_eq!(files.len(), 0);
} else {
assert_eq!(files.len(), 1);
}
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
}
Ok(())
}
#[test]
fn test_flow_signle_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
..
} = suite.new_case();
let listener = Handler::from(&suite.local_app_data);
let size = 10_i32;
let relative_file_path = std::path::Path::new("one/two/test.txt");
for i in 1_i32..=size {
std::fs::create_dir_all(std::path::Path::new(&project.path).join("one/two"))?;
// create a session with a single file change and flush it
std::fs::write(
std::path::Path::new(&project.path).join(relative_file_path),
i.to_string(),
)?;
listener.handle(relative_file_path, &project.id)?;
}
// collect all operations from sessions in the reverse order
let mut operations: Vec<deltas::Operation> = vec![];
let session = gb_repository.get_current_session()?.unwrap();
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let deltas_reader = deltas::Reader::new(&session_reader);
let deltas_by_filepath = deltas_reader.read(None).unwrap();
for deltas in deltas_by_filepath.values() {
for delta in deltas {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
}
}
let reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let files = reader.files(None).unwrap();
let base_file = files.get(&relative_file_path.to_path_buf());
let mut text: Vec<char> = match base_file {
Some(reader::Content::UTF8(file)) => file.chars().collect(),
_ => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
Ok(())
}
#[test]
fn should_persist_branches_targets_state_between_sessions() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case_with_files(HashMap::from([(
path::PathBuf::from("test.txt"),
"hello world",
)]));
let listener = Handler::from(&suite.local_app_data);
let branch_writer = virtual_branches::branch::Writer::new(&gb_repository);
let target_writer = virtual_branches::target::Writer::new(&gb_repository);
let default_target = test_target();
target_writer.write_default(&default_target)?;
let vbranch0 = test_branch();
branch_writer.write(&vbranch0)?;
let vbranch1 = test_branch();
let vbranch1_target = test_target();
branch_writer.write(&vbranch1)?;
target_writer.write(&vbranch1.id, &vbranch1_target)?;
std::fs::write(project.path.join("test.txt"), "hello world!").unwrap();
listener.handle("test.txt", &project.id)?;
let flushed_session = gb_repository.flush(&project_repository, None).unwrap();
// create a new session
let session = gb_repository.get_or_create_current_session().unwrap();
assert_ne!(session.id, flushed_session.unwrap().id);
// ensure that the virtual branch is still there and selected
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let branches = virtual_branches::Iterator::new(&session_reader)
.unwrap()
.collect::<Result<Vec<virtual_branches::Branch>, crate::reader::Error>>()
.unwrap()
.into_iter()
.collect::<Vec<virtual_branches::Branch>>();
assert_eq!(branches.len(), 2);
let branch_ids = branches.iter().map(|b| b.id).collect::<Vec<_>>();
assert!(branch_ids.contains(&vbranch0.id));
assert!(branch_ids.contains(&vbranch1.id));
let target_reader = virtual_branches::target::Reader::new(&session_reader);
assert_eq!(target_reader.read_default().unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch0.id).unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch1.id).unwrap(), vbranch1_target);
Ok(())
}
#[test]
fn should_restore_branches_targets_state_from_head_session() -> Result<()> {
let suite = Suite::default();
let Case {
gb_repository,
project,
project_repository,
..
} = suite.new_case_with_files(HashMap::from([(
path::PathBuf::from("test.txt"),
"hello world",
)]));
let listener = Handler::from(&suite.local_app_data);
let branch_writer = virtual_branches::branch::Writer::new(&gb_repository);
let target_writer = virtual_branches::target::Writer::new(&gb_repository);
let default_target = test_target();
target_writer.write_default(&default_target)?;
let vbranch0 = test_branch();
branch_writer.write(&vbranch0)?;
let vbranch1 = test_branch();
let vbranch1_target = test_target();
branch_writer.write(&vbranch1)?;
target_writer.write(&vbranch1.id, &vbranch1_target)?;
std::fs::write(project.path.join("test.txt"), "hello world!").unwrap();
listener.handle("test.txt", &project.id).unwrap();
let flushed_session = gb_repository.flush(&project_repository, None).unwrap();
// hard delete branches state from disk
std::fs::remove_dir_all(gb_repository.root()).unwrap();
// create a new session
let session = gb_repository.get_or_create_current_session().unwrap();
assert_ne!(session.id, flushed_session.unwrap().id);
// ensure that the virtual branch is still there and selected
let session_reader = sessions::Reader::open(&gb_repository, &session).unwrap();
let branches = virtual_branches::Iterator::new(&session_reader)
.unwrap()
.collect::<Result<Vec<virtual_branches::Branch>, crate::reader::Error>>()
.unwrap()
.into_iter()
.collect::<Vec<virtual_branches::Branch>>();
assert_eq!(branches.len(), 2);
let branch_ids = branches.iter().map(|b| b.id).collect::<Vec<_>>();
assert!(branch_ids.contains(&vbranch0.id));
assert!(branch_ids.contains(&vbranch1.id));
let target_reader = virtual_branches::target::Reader::new(&session_reader);
assert_eq!(target_reader.read_default().unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch0.id).unwrap(), default_target);
assert_eq!(target_reader.read(&vbranch1.id).unwrap(), vbranch1_target);
Ok(())
}
}