From 06c70bb0bd418c9f4be865263db6c75dd6a9fb68 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 10 Apr 2026 23:04:26 -0700 Subject: [PATCH 01/16] Basic repo creation implemented, with tests. --- Cargo.lock | 1 + Cargo.toml | 50 ++++-- src/server/config.rs | 27 +++ src/server/encryption.rs | 2 +- src/server/gitsync/mod.rs | 342 ++++++++++++++++++++++++++++++++++++++ src/server/mod.rs | 3 + 6 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 src/server/gitsync/mod.rs diff --git a/Cargo.lock b/Cargo.lock index ef6b1b28e..3928567d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2478,6 +2478,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "google-cloud-storage", + "hex", "httptest", "idb", "libc", diff --git a/Cargo.toml b/Cargo.toml index 5400909f7..c320881eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2021" rust-version = "1.91.1" [workspace] -members = [ "xtask" ] +members = ["xtask"] resolver = "2" [lib] @@ -24,15 +24,32 @@ crate-type = ["cdylib", "rlib"] default = ["sync", "bundled", "storage", "tls-webpki-roots"] # Support for all sync solutions -sync = ["server-sync", "server-gcp", "server-aws", "server-local"] +sync = ["server-sync", "server-gcp", "server-aws", "server-local", "server-git"] # Support for sync to a server server-sync = ["encryption", "http"] # Support for sync to GCP -server-gcp = ["cloud", "encryption", "http", "dep:google-cloud-storage", "dep:reqwest-middleware"] +server-gcp = [ + "cloud", + "encryption", + "http", + "dep:google-cloud-storage", + "dep:reqwest-middleware", +] # Support for sync to AWS -server-aws = ["cloud", "encryption", "http", "dep:aws-sdk-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:aws-smithy-types"] +server-aws = [ + "cloud", + "encryption", + "http", + "dep:aws-sdk-s3", + "dep:aws-config", + "dep:aws-credential-types", + "dep:aws-smithy-runtime-api", + "dep:aws-smithy-types", +] # Suppport for sync to another SQLite database on the same machine server-local = ["dep:rusqlite"] +# Support for sync via Git +server-git = ["git-sync"] # Support for all task storage backends (except indexeddb, which only works on WASM builds) storage = ["storage-sqlite"] @@ -45,6 +62,8 @@ storage-indexeddb = ["dep:idb", "dep:web-sys"] encryption = ["dep:ring"] # (private) Generic support for cloud sync cloud = [] +# (private) Support for Git based sync +git-sync = ["dep:hex"] # static bundling of dependencies bundled = ["rusqlite/bundled"] # use CA roots built into the library for all HTTPS access @@ -64,12 +83,13 @@ async-trait = "0.1.89" byteorder = "1.5" chrono = { version = "^0.4.44", features = ["serde"] } flate2 = "1" +hex = { version = "0.4", optional = true } idb = { version = "0.6.4", optional = true } log = "^0.4.17" # Reqwest is "stuck" at 0.12 because that's what google-cloud-storage depends on. reqwest = { version = "0.12", default-features = false, optional = true } ring = { version = "0.17", optional = true } -rusqlite = { version = "0.39", features = [ "fallible_uint" ], optional = true} +rusqlite = { version = "0.39", features = ["fallible_uint"], optional = true } serde_json = "^1.0" serde = { version = "^1.0.147", features = ["derive"] } strum = "0.28" @@ -82,7 +102,9 @@ url = { version = "2", optional = true } ## wasm-only dependencies. [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { version = "1", features = ["js"] } -ring = { version = "0", optional = true, features = ["wasm32_unknown_unknown_js"] } +ring = { version = "0", optional = true, features = [ + "wasm32_unknown_unknown_js", +] } getrandom = { version = "0.4", features = ["wasm_js"] } wasm-bindgen = { version = "0.2" } wasm-bindgen-futures = { version = "0.4" } @@ -101,16 +123,24 @@ web-sys = { version = "0.3.83", optional = true, features = [ "EventTarget", "Event", "console", -]} +] } ## non-wasm dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] aws-sdk-s3 = { version = "1.127", default-features = false, optional = true } -aws-config = { version = "1.8", default-features = false, features = ["rt-tokio", "behavior-version-latest"], optional = true } -aws-credential-types = { version = "1", default-features = false, features = ["hardcoded-credentials"], optional = true } +aws-config = { version = "1.8", default-features = false, features = [ + "rt-tokio", + "behavior-version-latest", +], optional = true } +aws-credential-types = { version = "1", default-features = false, features = [ + "hardcoded-credentials", +], optional = true } aws-smithy-runtime-api = { version = "1.11", default-features = false, optional = true } aws-smithy-types = { version = "1.4", default-features = false, optional = true } -google-cloud-storage = { version = "0.24.0", default-features = false, features = ["rustls-tls", "auth"], optional = true } +google-cloud-storage = { version = "0.24.0", default-features = false, features = [ + "rustls-tls", + "auth", +], optional = true } reqwest-middleware = { version = "0.4", optional = true } ## common dev-dependencies diff --git a/src/server/config.rs b/src/server/config.rs index fd2396a0a..3f3289391 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -118,6 +118,26 @@ pub enum ServerConfig { /// be any suitably un-guessable string of bytes. encryption_secret: Vec, }, + /// A git repository + #[cfg(feature = "git-sync")] + Git { + /// The path to the local repo. + local_path: PathBuf, + /// The branch to use. + branch: String, + /// The remote repo. + /// + /// This can either be a named remote such as `origin` or a full git + /// url such as `git@myserver.com:/path/to/repo.git` + /// If `None` will use `origin` if the local repo has one. + /// Otherwise will operate in local only mode. + remote: String, + /// Don't clone/push/pull to `remote` even if it is defined. + local_only: bool, + /// Private encryption secret used to encrypt all data sent to the server. This can + /// be any suitably un-guessable string of bytes. + encryption_secret: Vec, + }, } impl ServerConfig { @@ -162,6 +182,13 @@ impl ServerConfig { ) .await?, ), + ServerConfig::Git { + local_path, + branch, + remote, + local_only, + encryption_secret, + } => todo!(), }) } } diff --git a/src/server/encryption.rs b/src/server/encryption.rs index aa2c52e85..40e8c3f53 100644 --- a/src/server/encryption.rs +++ b/src/server/encryption.rs @@ -27,7 +27,7 @@ impl Cryptor { } /// Generate a suitable random salt. - #[cfg(any(test, feature = "cloud"))] // server-sync uses the clientId as the salt. + #[cfg(any(test, feature = "cloud", feature = "git-sync"))] // server-sync uses the clientId as the salt. pub(super) fn gen_salt() -> Result> { let rng = rand::SystemRandom::new(); let mut salt = [0u8; 16]; diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs new file mode 100644 index 000000000..059995e30 --- /dev/null +++ b/src/server/gitsync/mod.rs @@ -0,0 +1,342 @@ +use crate::errors::Result; +use crate::server::encryption::Cryptor; +use crate::server::{ + AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, + VersionId, +}; +use crate::Error; +use async_trait::async_trait; +use log::info; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; +#[derive(Serialize, Deserialize, Debug)] +struct Version { + version_id: VersionId, + parent_version_id: VersionId, + history_segment: HistorySegment, +} + +/// The meta file holds the UUID of the most recent Version and the salt. +#[derive(Serialize, Deserialize, Debug)] +struct Meta { + #[serde(with = "uuid::serde::simple")] + latest: VersionId, + salt: String, // hex-encoded +} + +pub(crate) struct GitSyncServer { + meta: Meta, + local_path: PathBuf, + branch: String, + remote: String, + local_only: bool, + cryptor: Cryptor, +} + +/// Run a git command in a given directory, returning an error if it exits non-zero. +fn git_cmd(dir: &Path, args: &[&str]) -> Result<()> { + let status = Command::new("git").args(args).current_dir(dir).status()?; + if !status.success() { + return Err(Error::Server(format!( + "git {} failed with status {}", + args.join(" "), + status + ))); + } + Ok(()) +} + +impl GitSyncServer { + pub(crate) fn new( + local_path: PathBuf, + branch: String, + remote: String, + local_only: bool, + encryption_secret: Vec, + ) -> Result { + let meta = Self::init_repo(&local_path, &branch, &remote, local_only)?; + let salt_bytes = hex::decode(&meta.salt) + .map_err(|e| Error::Server(format!("invalid salt in meta file: {e}")))?; + let cryptor = Cryptor::new(&salt_bytes, &encryption_secret.into())?; + let server = GitSyncServer { + meta, + local_path, + branch, + remote, + local_only, + cryptor, + }; + Ok(server) + } + + fn init_repo(local_path: &Path, branch: &str, remote: &str, local_only: bool) -> Result { + // Create the local directory if needed. + fs::create_dir_all(local_path)?; + + // Check if path is already a git repo. + let is_repo = Command::new("git") + .arg("rev-parse") + .current_dir(local_path) + .output()? + .status + .success(); + + if !is_repo { + if local_only { + info!("Creating new repo at {:?}", local_path); + git_cmd(local_path, &["init"])?; + } else { + info!("Cloning repo from {:?} to {:?}", remote, local_path); + let parent = local_path + .parent() + .ok_or_else(|| Error::Server("local_path has no parent".into()))?; + let dir_name = local_path + .file_name() + .ok_or_else(|| Error::Server("local_path has no file name".into()))? + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + git_cmd(parent, &["clone", remote, dir_name])?; + } + // Set identity so commits work in environments without a global git config. + git_cmd(local_path, &["config", "user.email", "taskchampion@local"])?; + git_cmd(local_path, &["config", "user.name", "taskchampion"])?; + } + + // Switch to the requested branch. Try checking out an existing branch first, + // only create a new one if that fails. + info!("Switching branch to {:?}", branch); + let checkout_ok = Command::new("git") + .args(["checkout", branch]) + .current_dir(local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if !checkout_ok { + // For a brand-new repo (no commits) `git checkout -b` also fails, so use + // `git symbolic-ref` to point HEAD at the desired branch without needing a commit. + let has_commits = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if has_commits { + git_cmd(local_path, &["checkout", "-b", branch])?; + } else { + git_cmd( + local_path, + &["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch)], + )?; + } + } + + // Check for meta file, create and commit if missing. + let meta_path = local_path.join("meta"); + let meta = match File::open(&meta_path) { + Ok(mut file) => { + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + serde_json::from_str(&contents)? + } + Err(_) => { + let m = Meta { + latest: Uuid::nil(), + salt: hex::encode(Cryptor::gen_salt()?), + }; + let f = File::create_new(&meta_path)?; + serde_json::to_writer(f, &m)?; + git_cmd(local_path, &["add", "meta"])?; + git_cmd(local_path, &["commit", "-m", "init taskchampion repo"])?; + m + } + }; + + Ok(meta) + } + + /// Stage the given paths and create a commit. + fn stage_and_commit(&self, paths: &[&Path], message: &str) -> Result<()> { + for path in paths { + let path_str = path + .to_str() + .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?; + git_cmd(&self.local_path, &["add", path_str])?; + } + git_cmd(&self.local_path, &["commit", "-m", message])?; + Ok(()) + } + + /// Read the meta file from disk and update self.meta. + fn read_meta(&mut self) -> Result<()> { + let meta_path = self.local_path.join("meta"); + let mut file = File::open(&meta_path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + self.meta = serde_json::from_str(&contents)?; + Ok(()) + } + + /// Serialise self.meta to the meta file and return its path. + fn write_meta(&self) -> Result { + let meta_path = self.local_path.join("meta"); + let f = File::create(&meta_path)?; + serde_json::to_writer(f, &self.meta)?; + Ok(meta_path) + } +} + +#[async_trait(?Send)] +impl Server for GitSyncServer { + async fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> Result<(AddVersionResult, SnapshotUrgency)> { + todo!(); + // check the parent_version_id for linearity + // if parent_version_id != self.meta.latest { + // // Pull and try again if remote exists and not local_only + + // // Else fail + // return Ok(( + // AddVersionResult::ExpectedParentVersion(self.meta.latest), + // SnapshotUrgency::None, + // )); + // } + + // // invent a new ID for this version + // let version_id = Uuid::new_v4(); + // let version_path = self.add_version_by_parent_version_id(Version { + // version_id, + // parent_version_id, + // history_segment, + // })?; + // let meta = self.set_latest_version_id(version_id)?; + // git add and commit version_path and meta + // if remote mode: + // push + // if push fails + // revert local commit + // re-read latest + // return Ok(( + // AddVersionResult::ExpectedParentVersion(self.meta.latest), + // SnapshotUrgency::None, + // )); + // Ok((AddVersionResult::Ok(version_id), SnapshotUrgency::None)) + } + + async fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> Result { + todo!(); + // if let Some(version) = self.get_version_by_parent_version_id(parent_version_id)? { + // Ok(GetVersionResult::Version { + // version_id: version.version_id, + // parent_version_id: version.parent_version_id, + // history_segment: version.history_segment, + // }) + // } else { + // Ok(GetVersionResult::NoSuchVersion) + // } + } + + async fn add_snapshot(&mut self, _version_id: VersionId, _snapshot: Snapshot) -> Result<()> { + todo!(); + } + + async fn get_snapshot(&mut self) -> Result> { + todo!(); + } +} + +#[cfg(test)] +mod test { + use super::*; + use tempfile::TempDir; + + fn make_server(dir: &Path) -> Result { + GitSyncServer::new( + dir.to_path_buf(), + "main".into(), + "".into(), + true, + b"test-secret".to_vec(), + ) + } + + #[test] + fn test_init_creates_repo_and_meta() -> Result<()> { + let tmp = TempDir::new()?; + let server = make_server(tmp.path())?; + assert!(tmp.path().join("meta").exists()); + assert_eq!(server.meta.latest, Uuid::nil()); + eprintln!("tmp dir: {}", tmp.path().display()); + std::mem::forget(tmp); + Ok(()) + } + + #[test] + fn test_init_idempotent() -> Result<()> { + let tmp = TempDir::new()?; + let s1 = make_server(tmp.path())?; + // second call should succeed and load the same meta (same salt) + let s2 = make_server(tmp.path())?; + assert_eq!(s1.meta.salt, s2.meta.salt); + Ok(()) + } + + #[test] + fn test_read_write_meta_roundtrip() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let new_id = Uuid::new_v4(); + server.meta.latest = new_id; + server.write_meta()?; + + server.meta.latest = Uuid::nil(); // clobber in-memory value + server.read_meta()?; + assert_eq!(server.meta.latest, new_id); + Ok(()) + } + + #[test] + fn test_stage_and_commit() -> Result<()> { + let tmp = TempDir::new()?; + let server = make_server(tmp.path())?; + + // Write a new file, stage and commit it. + let new_file = tmp.path().join("testfile"); + std::fs::write(&new_file, b"hello, taskchampion")?; + server.stage_and_commit(&[&new_file], "test commit")?; + + // Verify the commit exists, git show HEAD succeeds only if there is a HEAD commit. + git_cmd(tmp.path(), &["show", "HEAD"])?; + eprintln!("tmp dir: {}", tmp.path().display()); + std::mem::forget(tmp); + Ok(()) + } + + // #[tokio::test] + // async fn test_empty() -> Result<()> { ... } + + // #[tokio::test] + // async fn test_add_zero_base() -> Result<()> { ... } + + // #[tokio::test] + // async fn test_add_nonzero_base() -> Result<()> { ... } + + // #[tokio::test] + // async fn test_add_nonzero_base_forbidden() -> Result<()> { ... } + + // #[tokio::test] + // async fn test_snapshot() -> Result<()> { ... } + + // #[tokio::test] + // async fn test_conflict_with_local_remote() -> Result<()> { ... } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 33bcc41aa..3c5c93eca 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -31,6 +31,9 @@ mod sync; #[cfg(feature = "cloud")] mod cloud; +#[cfg(feature = "git-sync")] +mod gitsync; + pub use config::*; pub use types::*; From a9b32174e266d1be93a9d444b07d198ba7aedf0c Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sat, 11 Apr 2026 00:25:01 -0700 Subject: [PATCH 02/16] add git push/pull helpers --- src/server/gitsync/mod.rs | 142 +++++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 18 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 059995e30..1fdbcf5ec 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -25,7 +25,7 @@ struct Version { struct Meta { #[serde(with = "uuid::serde::simple")] latest: VersionId, - salt: String, // hex-encoded + salt: String, // hex-encoded } pub(crate) struct GitSyncServer { @@ -187,6 +187,30 @@ impl GitSyncServer { serde_json::to_writer(f, &self.meta)?; Ok(meta_path) } + + /// Fetch and fast-forward to the remote branch. No-op in local-only mode. + fn pull(&self) -> Result<()> { + if self.local_only { + return Ok(()); + } + git_cmd(&self.local_path, &["fetch", &self.remote, &self.branch])?; + git_cmd(&self.local_path, &["reset", "--hard", "FETCH_HEAD"])?; + Ok(()) + } + + /// Push to the remote branch. Returns `true` on success, `false` if the push is rejected + /// Always returns `true` in local-only mode. + fn push(&self) -> Result { + if self.local_only { + return Ok(true); + } + let status = Command::new("git") + .args(["push", &self.remote, &self.branch]) + .current_dir(&self.local_path) + .stderr(std::process::Stdio::null()) + .status()?; + Ok(status.success()) + } } #[async_trait(?Send)] @@ -269,14 +293,36 @@ mod test { ) } + /// Create a bare repo to act as a remote, then clone it into `clone_dir`. + /// Returns a GitSyncServer backed by the clone, with the bare repo as its remote. + fn make_server_with_remote(bare_dir: &Path, clone_dir: &Path) -> Result { + // Initialise the bare remote. + git_cmd( + bare_dir.parent().unwrap(), + &[ + "init", + "--bare", + bare_dir.file_name().unwrap().to_str().unwrap(), + ], + )?; + let bare_url = bare_dir.to_str().unwrap(); + GitSyncServer::new( + clone_dir.to_path_buf(), + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + ) + } + #[test] fn test_init_creates_repo_and_meta() -> Result<()> { let tmp = TempDir::new()?; let server = make_server(tmp.path())?; assert!(tmp.path().join("meta").exists()); assert_eq!(server.meta.latest, Uuid::nil()); - eprintln!("tmp dir: {}", tmp.path().display()); - std::mem::forget(tmp); + // eprintln!("tmp dir: {}", tmp.path().display()); + // std::mem::forget(tmp); Ok(()) } @@ -314,29 +360,89 @@ mod test { let new_file = tmp.path().join("testfile"); std::fs::write(&new_file, b"hello, taskchampion")?; server.stage_and_commit(&[&new_file], "test commit")?; - // Verify the commit exists, git show HEAD succeeds only if there is a HEAD commit. git_cmd(tmp.path(), &["show", "HEAD"])?; - eprintln!("tmp dir: {}", tmp.path().display()); - std::mem::forget(tmp); + // eprintln!("tmp dir: {}", tmp.path().display()); + // std::mem::forget(tmp); Ok(()) } - // #[tokio::test] - // async fn test_empty() -> Result<()> { ... } + #[test] + fn test_push_and_pull() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + // Set up first clone (initialises remote with the meta commit). + let server1 = make_server_with_remote(&bare, &clone1)?; + server1.push()?; + + // Clone a second copy directly via git. + let bare_url = bare.to_str().unwrap(); + git_cmd(tmp.path(), &["clone", bare_url, "clone2"])?; + git_cmd(&clone2, &["config", "user.email", "taskchampion@local"])?; + git_cmd(&clone2, &["config", "user.name", "taskchampion"])?; + + // Write a new file in clone1 and push it. + let new_file = clone1.join("testfile"); + std::fs::write(&new_file, b"hello")?; + server1.stage_and_commit(&[&new_file], "add testfile")?; + assert!(server1.push()?); + + // Build a server for clone2 and pull + // It should see the new file. + let bare_url_str: String = bare_url.into(); + let mut server2 = GitSyncServer::new( + clone2.clone(), + "main".into(), + bare_url_str, + false, + b"test-secret".to_vec(), + )?; + server2.pull()?; + assert!(clone2.join("testfile").exists()); + // eprintln!("tmp dir: {}", tmp.path().display()); + // std::mem::forget(tmp); + Ok(()) + } + + #[test] + fn test_push_rejected_on_conflict() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); - // #[tokio::test] - // async fn test_add_zero_base() -> Result<()> { ... } + let server1 = make_server_with_remote(&bare, &clone1)?; + server1.push()?; - // #[tokio::test] - // async fn test_add_nonzero_base() -> Result<()> { ... } + // Clone a second copy. + let bare_url = bare.to_str().unwrap(); + git_cmd(tmp.path(), &["clone", bare_url, "clone2"])?; + git_cmd(&clone2, &["config", "user.email", "taskchampion@local"])?; + git_cmd(&clone2, &["config", "user.name", "taskchampion"])?; - // #[tokio::test] - // async fn test_add_nonzero_base_forbidden() -> Result<()> { ... } + let mut server2 = GitSyncServer::new( + clone2.clone(), + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; - // #[tokio::test] - // async fn test_snapshot() -> Result<()> { ... } + // clone1 pushes first. + let f1 = clone1.join("f1"); + std::fs::write(&f1, b"from clone1")?; + server1.stage_and_commit(&[&f1], "clone1 commit")?; + assert!(server1.push()?); - // #[tokio::test] - // async fn test_conflict_with_local_remote() -> Result<()> { ... } + // clone2 also commits (diverged history) and tries to push — should be rejected. + let f2 = clone2.join("f2"); + std::fs::write(&f2, b"from clone2")?; + server2.stage_and_commit(&[&f2], "clone2 commit")?; + assert!(!server2.push()?); + + Ok(()) + } } From 26fbaaff08d9f049b65098d081547c2fe510fb23 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sat, 11 Apr 2026 02:21:24 -0700 Subject: [PATCH 03/16] add version and then to bed --- src/server/gitsync/mod.rs | 206 ++++++++++++++++++++++++++------------ 1 file changed, 143 insertions(+), 63 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 1fdbcf5ec..320e69acd 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -2,7 +2,7 @@ use crate::errors::Result; use crate::server::encryption::Cryptor; use crate::server::{ AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, - VersionId, + VersionId, NIL_VERSION_ID, }; use crate::Error; use async_trait::async_trait; @@ -211,6 +211,23 @@ impl GitSyncServer { .status()?; Ok(status.success()) } + + /// Encrypt and write a version file. Returns the file path. + fn add_version_by_parent_version_id(&self, version: &Version) -> Result { + use crate::server::encryption::{Sealed, Unsealed}; + let sealed = self.cryptor.seal(Unsealed { + version_id: version.version_id, + payload: version.history_segment.clone(), + })?; + let filename = format!( + "v-{}-{}", + version.parent_version_id.simple(), + version.version_id.simple() + ); + let path = self.local_path.join(&filename); + std::fs::write(&path, Vec::::from(sealed))?; + Ok(path) + } } #[async_trait(?Send)] @@ -220,37 +237,46 @@ impl Server for GitSyncServer { parent_version_id: VersionId, history_segment: HistorySegment, ) -> Result<(AddVersionResult, SnapshotUrgency)> { - todo!(); - // check the parent_version_id for linearity - // if parent_version_id != self.meta.latest { - // // Pull and try again if remote exists and not local_only - - // // Else fail - // return Ok(( - // AddVersionResult::ExpectedParentVersion(self.meta.latest), - // SnapshotUrgency::None, - // )); - // } + // Accept any parent when the repo is empty (latest == NIL). + // Otherwise check if parent is in latest. If it isn't, pull recheck. + if self.meta.latest != Uuid::nil() && parent_version_id != self.meta.latest { + self.pull()?; + self.read_meta()?; + if parent_version_id != self.meta.latest { + return Ok(( + AddVersionResult::ExpectedParentVersion(self.meta.latest), + SnapshotUrgency::None, + )); + } + } - // // invent a new ID for this version - // let version_id = Uuid::new_v4(); - // let version_path = self.add_version_by_parent_version_id(Version { - // version_id, - // parent_version_id, - // history_segment, - // })?; - // let meta = self.set_latest_version_id(version_id)?; - // git add and commit version_path and meta - // if remote mode: - // push - // if push fails - // revert local commit - // re-read latest - // return Ok(( - // AddVersionResult::ExpectedParentVersion(self.meta.latest), - // SnapshotUrgency::None, - // )); - // Ok((AddVersionResult::Ok(version_id), SnapshotUrgency::None)) + // Create the new version and write it to file. + let version_id = Uuid::new_v4(); + let version = Version { + version_id, + parent_version_id, + history_segment, + }; + let version_path = self.add_version_by_parent_version_id(&version)?; + self.meta.latest = version_id; + let meta_path = self.write_meta()?; + + // Commit and push, reverting if push fails. + self.stage_and_commit(&[&version_path, &meta_path], "add version")?; + + if !self.push()? { + // Push was rejected (non-fast-forward). Undo the commit and re-read remote state. + git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; + std::fs::remove_file(&version_path)?; + self.pull()?; + self.read_meta()?; + return Ok(( + AddVersionResult::ExpectedParentVersion(self.meta.latest), + SnapshotUrgency::None, + )); + } + + Ok((AddVersionResult::Ok(version_id), SnapshotUrgency::None)) } async fn get_child_version( @@ -351,21 +377,6 @@ mod test { Ok(()) } - #[test] - fn test_stage_and_commit() -> Result<()> { - let tmp = TempDir::new()?; - let server = make_server(tmp.path())?; - - // Write a new file, stage and commit it. - let new_file = tmp.path().join("testfile"); - std::fs::write(&new_file, b"hello, taskchampion")?; - server.stage_and_commit(&[&new_file], "test commit")?; - // Verify the commit exists, git show HEAD succeeds only if there is a HEAD commit. - git_cmd(tmp.path(), &["show", "HEAD"])?; - // eprintln!("tmp dir: {}", tmp.path().display()); - // std::mem::forget(tmp); - Ok(()) - } #[test] fn test_push_and_pull() -> Result<()> { @@ -407,42 +418,111 @@ mod test { Ok(()) } - #[test] - fn test_push_rejected_on_conflict() -> Result<()> { + + #[tokio::test] + async fn test_add_zero_base() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + match server.add_version(NIL_VERSION_ID, history.clone()).await?.0 { + AddVersionResult::ExpectedParentVersion(_) => { + panic!("should have accepted the version") + } + AddVersionResult::Ok(version_id) => { + // Verify the version file exists on disk. + let filename = format!("v-{}-{}", NIL_VERSION_ID.simple(), version_id.simple()); + assert!( + tmp.path().join(&filename).exists(), + "version file missing: {filename}" + ); + // Verify meta was updated. + assert_eq!(server.meta.latest, version_id); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_add_nonzero_base() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4(); + + // OK because latest == NIL (repo is empty). + match server.add_version(parent_version_id, history).await?.0 { + AddVersionResult::ExpectedParentVersion(_) => { + panic!("should have accepted the version") + } + AddVersionResult::Ok(version_id) => { + assert_eq!(server.meta.latest, version_id); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_add_nonzero_base_forbidden() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4(); + + // Add a first version. + if let (AddVersionResult::ExpectedParentVersion(_), _) = server + .add_version(parent_version_id, history.clone()) + .await? + { + panic!("should have accepted the first version"); + } + + // Try to add another with the same (now stale) parent, should be rejected. + match server.add_version(parent_version_id, history).await?.0 { + AddVersionResult::Ok(_) => panic!("should not have accepted the version"), + AddVersionResult::ExpectedParentVersion(expected) => { + assert_eq!(expected, server.meta.latest); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_add_version_conflict_with_remote() -> Result<()> { let tmp = TempDir::new()?; let bare = tmp.path().join("bare"); let clone1 = tmp.path().join("clone1"); let clone2 = tmp.path().join("clone2"); - let server1 = make_server_with_remote(&bare, &clone1)?; + let mut server1 = make_server_with_remote(&bare, &clone1)?; server1.push()?; - // Clone a second copy. let bare_url = bare.to_str().unwrap(); git_cmd(tmp.path(), &["clone", bare_url, "clone2"])?; git_cmd(&clone2, &["config", "user.email", "taskchampion@local"])?; git_cmd(&clone2, &["config", "user.name", "taskchampion"])?; - let mut server2 = GitSyncServer::new( - clone2.clone(), + clone2, "main".into(), bare_url.into(), false, b"test-secret".to_vec(), )?; - // clone1 pushes first. - let f1 = clone1.join("f1"); - std::fs::write(&f1, b"from clone1")?; - server1.stage_and_commit(&[&f1], "clone1 commit")?; - assert!(server1.push()?); - - // clone2 also commits (diverged history) and tries to push — should be rejected. - let f2 = clone2.join("f2"); - std::fs::write(&f2, b"from clone2")?; - server2.stage_and_commit(&[&f2], "clone2 commit")?; - assert!(!server2.push()?); + // server1 adds a version from NIL parent and pushes successfully. + let (result1, _) = server1.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + let v1_id = match result1 { + AddVersionResult::Ok(id) => id, + AddVersionResult::ExpectedParentVersion(_) => panic!("server1 add failed"), + }; + // server2 also tries to add from NIL, should fail with ExpectedParentVersion pointing at v1. + let (result2, _) = server2.add_version(NIL_VERSION_ID, b"v2".to_vec()).await?; + match result2 { + AddVersionResult::Ok(_) => panic!("server2 should have been rejected"), + AddVersionResult::ExpectedParentVersion(expected) => { + assert_eq!(expected, v1_id); + } + } Ok(()) } } From e69b03b8c027259ac438809470939f06b9841f6d Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sat, 11 Apr 2026 12:20:28 -0700 Subject: [PATCH 04/16] Add get_version_by_parent_version_id and test --- Cargo.lock | 1 + Cargo.toml | 1 + src/server/config.rs | 10 ++++- src/server/encryption.rs | 2 + src/server/gitsync/mod.rs | 79 +++++++++++++++++++++++++++++++++++---- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3928567d5..0146ead8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2477,6 +2477,7 @@ dependencies = [ "chrono", "flate2", "getrandom 0.4.2", + "glob", "google-cloud-storage", "hex", "httptest", diff --git a/Cargo.toml b/Cargo.toml index c320881eb..04bd4def5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ tokio = { version = "1", features = ["macros", "sync", "rt"] } thiserror = "2.0" uuid = { version = "^1.23.0", features = ["serde", "v4"] } url = { version = "2", optional = true } +glob = "0.3.3" ## wasm-only dependencies. [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/server/config.rs b/src/server/config.rs index 3f3289391..a54aa2163 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -1,5 +1,4 @@ use super::types::Server; -use crate::errors::Result; #[cfg(feature = "server-aws")] pub use crate::server::cloud::aws::AwsCredentials; #[cfg(feature = "server-aws")] @@ -12,6 +11,7 @@ use crate::server::cloud::CloudServer; use crate::server::local::LocalServer; #[cfg(feature = "server-sync")] use crate::server::sync::SyncServer; +use crate::{errors::Result, server::gitsync::GitSyncServer}; #[cfg(feature = "server-local")] use std::path::PathBuf; #[cfg(feature = "server-sync")] @@ -188,7 +188,13 @@ impl ServerConfig { remote, local_only, encryption_secret, - } => todo!(), + } => Box::new(GitSyncServer::new( + local_path, + branch, + remote, + local_only, + encryption_secret, + )?), }) } } diff --git a/src/server/encryption.rs b/src/server/encryption.rs index 40e8c3f53..951a4fd24 100644 --- a/src/server/encryption.rs +++ b/src/server/encryption.rs @@ -172,6 +172,7 @@ impl<'a> Envelope<'a> { /// A unsealed payload with an attached version_id. The version_id is used to /// validate the context of the payload on unsealing. +#[derive(Debug)] pub(super) struct Unsealed { pub(super) version_id: Uuid, pub(super) payload: Vec, @@ -184,6 +185,7 @@ impl From for Vec { } /// An encrypted payload +#[derive(Debug)] pub(super) struct Sealed { pub(super) version_id: Uuid, pub(super) payload: Vec, diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 320e69acd..9d0b19ac3 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -1,11 +1,12 @@ use crate::errors::Result; -use crate::server::encryption::Cryptor; +use crate::server::encryption::{Cryptor, Sealed, Unsealed}; use crate::server::{ AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, VersionId, NIL_VERSION_ID, }; use crate::Error; use async_trait::async_trait; +use glob::glob; use log::info; use serde::{Deserialize, Serialize}; use std::fs::{self, File}; @@ -116,7 +117,7 @@ impl GitSyncServer { .status()? .success(); if !checkout_ok { - // For a brand-new repo (no commits) `git checkout -b` also fails, so use + // For a brand-new repo `git checkout -b` also fails, so use // `git symbolic-ref` to point HEAD at the desired branch without needing a commit. let has_commits = Command::new("git") .args(["rev-parse", "HEAD"]) @@ -214,11 +215,11 @@ impl GitSyncServer { /// Encrypt and write a version file. Returns the file path. fn add_version_by_parent_version_id(&self, version: &Version) -> Result { - use crate::server::encryption::{Sealed, Unsealed}; - let sealed = self.cryptor.seal(Unsealed { + let unsealed = Unsealed { version_id: version.version_id, payload: version.history_segment.clone(), - })?; + }; + let sealed = self.cryptor.seal(unsealed)?; let filename = format!( "v-{}-{}", version.parent_version_id.simple(), @@ -228,6 +229,37 @@ impl GitSyncServer { std::fs::write(&path, Vec::::from(sealed))?; Ok(path) } + + /// Read and decrypt a version file. Returns a Version if found, None if not. + fn get_version_by_parent_version_id(&self, version: &VersionId) -> Option { + // glob to find file. + // v-PARENT-CHILD + let pattern = format!("{}/v-{}-*", self.local_path.to_str()?, version.simple()); + for entry in glob(&pattern).ok()? { + let result = (|| -> Option { + let path = entry.ok()?; + let mut buf = Vec::new(); + File::open(&path).ok()?.read_to_end(&mut buf).ok()?; + let filename = path.to_str()?; + let (_, version_id_str) = filename.rsplit_once('-')?; + let version_id = Uuid::parse_str(version_id_str).ok()?; + let sealed = Sealed { + version_id, + payload: buf, + }; + let unsealed = self.cryptor.unseal(sealed).ok()?; + Some(Version { + version_id: unsealed.version_id, + parent_version_id: *version, + history_segment: unsealed.payload, + }) + })(); + if let Some(v) = result { + return Some(v); + } + } + None + } } #[async_trait(?Send)] @@ -377,7 +409,6 @@ mod test { Ok(()) } - #[test] fn test_push_and_pull() -> Result<()> { let tmp = TempDir::new()?; @@ -404,7 +435,7 @@ mod test { // Build a server for clone2 and pull // It should see the new file. let bare_url_str: String = bare_url.into(); - let mut server2 = GitSyncServer::new( + let server2 = GitSyncServer::new( clone2.clone(), "main".into(), bare_url_str, @@ -418,7 +449,6 @@ mod test { Ok(()) } - #[tokio::test] async fn test_add_zero_base() -> Result<()> { let tmp = TempDir::new()?; @@ -525,4 +555,37 @@ mod test { } Ok(()) } + + #[tokio::test] + async fn test_get_version_by_parent_id() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4(); + + // Version doesn't exist yet -> None + assert!(server + .get_version_by_parent_version_id(&parent_version_id) + .is_none()); + + // Add a first version. + let (rst, _) = server + .add_version(parent_version_id, history.clone()) + .await?; + + let AddVersionResult::Ok(version_id) = rst else { + panic!("Couldn't add version"); + }; + + match server.get_version_by_parent_version_id(&parent_version_id) { + Some(version) => { + assert_eq!(version.parent_version_id, parent_version_id); + assert_eq!(version.history_segment, history); + assert_eq!(version.version_id, version_id); + } + None => panic!("Failed to read version"), + } + + Ok(()) + } } From 73989ec05fd8aca2af5d11d8b762910761eae1da Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sun, 12 Apr 2026 08:49:13 -0700 Subject: [PATCH 05/16] add get_child_version --- src/server/gitsync/mod.rs | 76 +++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 9d0b19ac3..44d540549 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -156,6 +156,9 @@ impl GitSyncServer { } }; + // Remove any untracked files left behind by interrupted writes. + git_cmd(local_path, &["clean", "-fd"])?; + Ok(meta) } @@ -196,6 +199,9 @@ impl GitSyncServer { } git_cmd(&self.local_path, &["fetch", &self.remote, &self.branch])?; git_cmd(&self.local_path, &["reset", "--hard", "FETCH_HEAD"])?; + // Remove any untracked files so orphaned version/snapshot files from + // interrupted writes cannot be served as valid versions. + git_cmd(&self.local_path, &["clean", "-fd"])?; Ok(()) } @@ -231,11 +237,17 @@ impl GitSyncServer { } /// Read and decrypt a version file. Returns a Version if found, None if not. - fn get_version_by_parent_version_id(&self, version: &VersionId) -> Option { + fn get_version_by_parent_version_id(&self, version: &VersionId) -> Result> { // glob to find file. // v-PARENT-CHILD - let pattern = format!("{}/v-{}-*", self.local_path.to_str()?, version.simple()); - for entry in glob(&pattern).ok()? { + let pattern = format!( + "{}/v-{}-*", + self.local_path + .to_str() + .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?, + version.simple() + ); + for entry in glob(&pattern).map_err(|e| Error::Server(format!("{:?}", e)))? { let result = (|| -> Option { let path = entry.ok()?; let mut buf = Vec::new(); @@ -255,10 +267,10 @@ impl GitSyncServer { }) })(); if let Some(v) = result { - return Some(v); + return Ok(Some(v)); } } - None + Ok(None) } } @@ -315,16 +327,21 @@ impl Server for GitSyncServer { &mut self, parent_version_id: VersionId, ) -> Result { - todo!(); - // if let Some(version) = self.get_version_by_parent_version_id(parent_version_id)? { - // Ok(GetVersionResult::Version { - // version_id: version.version_id, - // parent_version_id: version.parent_version_id, - // history_segment: version.history_segment, - // }) - // } else { - // Ok(GetVersionResult::NoSuchVersion) - // } + let version = match self.get_version_by_parent_version_id(&parent_version_id)? { + Some(v) => Some(v), + None => { + self.pull()?; + self.get_version_by_parent_version_id(&parent_version_id)? + } + }; + match version { + Some(v) => Ok(GetVersionResult::Version { + version_id: v.version_id, + parent_version_id: v.parent_version_id, + history_segment: v.history_segment, + }), + None => Ok(GetVersionResult::NoSuchVersion), + } } async fn add_snapshot(&mut self, _version_id: VersionId, _snapshot: Snapshot) -> Result<()> { @@ -338,6 +355,8 @@ impl Server for GitSyncServer { #[cfg(test)] mod test { + use std::any::Any; + use super::*; use tempfile::TempDir; @@ -557,16 +576,17 @@ mod test { } #[tokio::test] - async fn test_get_version_by_parent_id() -> Result<()> { + async fn get_child_version() -> Result<()> { let tmp = TempDir::new()?; let mut server = make_server(tmp.path())?; let history = b"1234".to_vec(); let parent_version_id = Uuid::new_v4(); - // Version doesn't exist yet -> None - assert!(server - .get_version_by_parent_version_id(&parent_version_id) - .is_none()); + // Version doesn't exist yet + assert_eq!( + server.get_child_version(parent_version_id).await?, + GetVersionResult::NoSuchVersion + ); // Add a first version. let (rst, _) = server @@ -577,13 +597,17 @@ mod test { panic!("Couldn't add version"); }; - match server.get_version_by_parent_version_id(&parent_version_id) { - Some(version) => { - assert_eq!(version.parent_version_id, parent_version_id); - assert_eq!(version.history_segment, history); - assert_eq!(version.version_id, version_id); + match server.get_child_version(parent_version_id).await? { + GetVersionResult::Version { + version_id: v_id, + parent_version_id: p_id, + history_segment: h_seg, + } => { + assert_eq!(v_id, version_id); + assert_eq!(p_id, parent_version_id); + assert_eq!(h_seg, history); } - None => panic!("Failed to read version"), + GetVersionResult::NoSuchVersion => panic!("Failed to read version"), } Ok(()) From 660881cd7bd6ef6463262152bf44d775ce683ac2 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sun, 12 Apr 2026 12:05:07 -0700 Subject: [PATCH 06/16] add snapshot system --- Cargo.lock | 162 ++++++++++++++++++++++++++-- Cargo.toml | 6 +- src/server/gitsync/mod.rs | 218 ++++++++++++++++++++++++++++++++------ 3 files changed, 344 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0146ead8b..6e05d1e3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -691,6 +725,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1010,13 +1050,19 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1348,7 +1394,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828" dependencies = [ - "indexmap", + "indexmap 2.13.0", "js-sys", "num-traits", "thiserror 2.0.18", @@ -1357,6 +1403,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1378,6 +1430,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1927,6 +1990,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2203,6 +2286,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2304,6 +2411,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2407,6 +2545,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -2479,7 +2623,6 @@ dependencies = [ "getrandom 0.4.2", "glob", "google-cloud-storage", - "hex", "httptest", "idb", "libc", @@ -2494,6 +2637,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "serde_with", "strum", "strum_macros", "tempfile", @@ -2681,7 +2825,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime", "toml_parser", "winnow", @@ -3031,7 +3175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -3057,7 +3201,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -3351,7 +3495,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -3382,7 +3526,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -3401,7 +3545,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 04bd4def5..fbc75b0f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ encryption = ["dep:ring"] # (private) Generic support for cloud sync cloud = [] # (private) Support for Git based sync -git-sync = ["dep:hex"] +git-sync = ["dep:serde_with", "dep:glob"] # static bundling of dependencies bundled = ["rusqlite/bundled"] # use CA roots built into the library for all HTTPS access @@ -83,7 +83,7 @@ async-trait = "0.1.89" byteorder = "1.5" chrono = { version = "^0.4.44", features = ["serde"] } flate2 = "1" -hex = { version = "0.4", optional = true } +serde_with = { version = "3.18.0", features = ["base64"], optional = true } idb = { version = "0.6.4", optional = true } log = "^0.4.17" # Reqwest is "stuck" at 0.12 because that's what google-cloud-storage depends on. @@ -98,7 +98,7 @@ tokio = { version = "1", features = ["macros", "sync", "rt"] } thiserror = "2.0" uuid = { version = "^1.23.0", features = ["serde", "v4"] } url = { version = "2", optional = true } -glob = "0.3.3" +glob = { version = "0.3.3", optional = true } ## wasm-only dependencies. [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 44d540549..a33e94a56 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -2,31 +2,51 @@ use crate::errors::Result; use crate::server::encryption::{Cryptor, Sealed, Unsealed}; use crate::server::{ AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, - VersionId, NIL_VERSION_ID, + VersionId, }; use crate::Error; use async_trait::async_trait; use glob::glob; use log::info; use serde::{Deserialize, Serialize}; +use serde_with::{base64::Base64, serde_as}; use std::fs::{self, File}; -use std::io::Read; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::TryFromFloatSecsError; use uuid::Uuid; + +#[serde_as] #[derive(Serialize, Deserialize, Debug)] struct Version { + #[serde(with = "uuid::serde::simple")] version_id: VersionId, + #[serde(with = "uuid::serde::simple")] parent_version_id: VersionId, + #[serde_as(as = "Base64")] history_segment: HistorySegment, } +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +struct SnapshotFile { + #[serde(with = "uuid::serde::simple")] + version_id: VersionId, + #[serde_as(as = "Base64")] + payload: Vec, +} + /// The meta file holds the UUID of the most recent Version and the salt. +#[serde_as] #[derive(Serialize, Deserialize, Debug)] struct Meta { #[serde(with = "uuid::serde::simple")] - latest: VersionId, - salt: String, // hex-encoded + latest_version: VersionId, + #[serde(with = "uuid::serde::simple")] + latest_snapshot: VersionId, + #[serde_as(as = "Base64")] + salt: Vec, } pub(crate) struct GitSyncServer { @@ -60,9 +80,7 @@ impl GitSyncServer { encryption_secret: Vec, ) -> Result { let meta = Self::init_repo(&local_path, &branch, &remote, local_only)?; - let salt_bytes = hex::decode(&meta.salt) - .map_err(|e| Error::Server(format!("invalid salt in meta file: {e}")))?; - let cryptor = Cryptor::new(&salt_bytes, &encryption_secret.into())?; + let cryptor = Cryptor::new(&meta.salt, &encryption_secret.into())?; let server = GitSyncServer { meta, local_path, @@ -145,8 +163,9 @@ impl GitSyncServer { } Err(_) => { let m = Meta { - latest: Uuid::nil(), - salt: hex::encode(Cryptor::gen_salt()?), + latest_version: Uuid::nil(), + salt: Cryptor::gen_salt()?, + latest_snapshot: Uuid::nil(), }; let f = File::create_new(&meta_path)?; serde_json::to_writer(f, &m)?; @@ -177,10 +196,9 @@ impl GitSyncServer { /// Read the meta file from disk and update self.meta. fn read_meta(&mut self) -> Result<()> { let meta_path = self.local_path.join("meta"); - let mut file = File::open(&meta_path)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - self.meta = serde_json::from_str(&contents)?; + let file = File::open(&meta_path)?; + let reader = BufReader::new(file); + self.meta = serde_json::from_reader(reader)?; Ok(()) } @@ -283,12 +301,13 @@ impl Server for GitSyncServer { ) -> Result<(AddVersionResult, SnapshotUrgency)> { // Accept any parent when the repo is empty (latest == NIL). // Otherwise check if parent is in latest. If it isn't, pull recheck. - if self.meta.latest != Uuid::nil() && parent_version_id != self.meta.latest { + if self.meta.latest_version != Uuid::nil() && parent_version_id != self.meta.latest_version + { self.pull()?; self.read_meta()?; - if parent_version_id != self.meta.latest { + if parent_version_id != self.meta.latest_version { return Ok(( - AddVersionResult::ExpectedParentVersion(self.meta.latest), + AddVersionResult::ExpectedParentVersion(self.meta.latest_version), SnapshotUrgency::None, )); } @@ -302,7 +321,7 @@ impl Server for GitSyncServer { history_segment, }; let version_path = self.add_version_by_parent_version_id(&version)?; - self.meta.latest = version_id; + self.meta.latest_version = version_id; let meta_path = self.write_meta()?; // Commit and push, reverting if push fails. @@ -315,7 +334,7 @@ impl Server for GitSyncServer { self.pull()?; self.read_meta()?; return Ok(( - AddVersionResult::ExpectedParentVersion(self.meta.latest), + AddVersionResult::ExpectedParentVersion(self.meta.latest_version), SnapshotUrgency::None, )); } @@ -344,18 +363,59 @@ impl Server for GitSyncServer { } } - async fn add_snapshot(&mut self, _version_id: VersionId, _snapshot: Snapshot) -> Result<()> { - todo!(); + async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { + // Write the snapshot to a file. + let unsealed = Unsealed { + version_id: version_id, + payload: snapshot, + }; + let sealed = self.cryptor.seal(unsealed)?; + let snapshot_file = SnapshotFile { + version_id: version_id, + payload: Vec::::from(sealed), + }; + let snapshot_path = self.local_path.join("snapshot"); + let f = File::create(&snapshot_path)?; + serde_json::to_writer(f, &snapshot_file)?; + + // Update the meta so it contains the latest snapshot. + self.meta.latest_snapshot = version_id; + let meta_path = self.write_meta()?; + + // Commit and push, reverting if push fails. + self.stage_and_commit(&[&snapshot_path, &meta_path], "add snapshot")?; + + if !self.push()? { + // Push was rejected (non-fast-forward). Undo the commit and re-read remote state. + git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; + std::fs::remove_file(&snapshot_path)?; + self.read_meta()?; + return Err(Error::Server("Couldn't push to remote.".into())); + } + Ok(()) } async fn get_snapshot(&mut self) -> Result> { - todo!(); + self.pull()?; + let snapshot_path = self.local_path.join("snapshot"); + if let Ok(file) = File::open(&snapshot_path) { + let reader = BufReader::new(file); + let s: SnapshotFile = serde_json::from_reader(reader)?; + let sealed = Sealed { + version_id: s.version_id, + payload: s.payload, + }; + let unsealed = self.cryptor.unseal(sealed)?; + return Ok(Some((unsealed.version_id, unsealed.payload))); + } else { + return Ok(None); + } } } #[cfg(test)] mod test { - use std::any::Any; + use crate::server::NIL_VERSION_ID; use super::*; use tempfile::TempDir; @@ -397,7 +457,7 @@ mod test { let tmp = TempDir::new()?; let server = make_server(tmp.path())?; assert!(tmp.path().join("meta").exists()); - assert_eq!(server.meta.latest, Uuid::nil()); + assert_eq!(server.meta.latest_version, Uuid::nil()); // eprintln!("tmp dir: {}", tmp.path().display()); // std::mem::forget(tmp); Ok(()) @@ -419,12 +479,12 @@ mod test { let mut server = make_server(tmp.path())?; let new_id = Uuid::new_v4(); - server.meta.latest = new_id; + server.meta.latest_version = new_id; server.write_meta()?; - server.meta.latest = Uuid::nil(); // clobber in-memory value + server.meta.latest_version = Uuid::nil(); // clobber in-memory value server.read_meta()?; - assert_eq!(server.meta.latest, new_id); + assert_eq!(server.meta.latest_version, new_id); Ok(()) } @@ -485,7 +545,7 @@ mod test { "version file missing: {filename}" ); // Verify meta was updated. - assert_eq!(server.meta.latest, version_id); + assert_eq!(server.meta.latest_version, version_id); } } Ok(()) @@ -504,7 +564,7 @@ mod test { panic!("should have accepted the version") } AddVersionResult::Ok(version_id) => { - assert_eq!(server.meta.latest, version_id); + assert_eq!(server.meta.latest_version, version_id); } } Ok(()) @@ -529,7 +589,7 @@ mod test { match server.add_version(parent_version_id, history).await?.0 { AddVersionResult::Ok(_) => panic!("should not have accepted the version"), AddVersionResult::ExpectedParentVersion(expected) => { - assert_eq!(expected, server.meta.latest); + assert_eq!(expected, server.meta.latest_version); } } Ok(()) @@ -596,7 +656,7 @@ mod test { let AddVersionResult::Ok(version_id) = rst else { panic!("Couldn't add version"); }; - + // Now we should be able to get the version. match server.get_child_version(parent_version_id).await? { GetVersionResult::Version { version_id: v_id, @@ -612,4 +672,102 @@ mod test { Ok(()) } + + #[tokio::test] + async fn test_get_child_version_from_remote() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + let mut server1 = make_server_with_remote(&bare, &clone1)?; + let bare_url = bare.to_str().unwrap(); + + // server1 adds a version and pushes it. + let (result, _) = server1 + .add_version(NIL_VERSION_ID, b"history".to_vec()) + .await?; + let AddVersionResult::Ok(version_id) = result else { + panic!("server1 add_version failed"); + }; + + let mut server2 = GitSyncServer::new( + clone2, + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; + + // get_child_version should pull and find the version. + match server2.get_child_version(NIL_VERSION_ID).await? { + GetVersionResult::Version { + version_id: v_id, + history_segment, + .. + } => { + assert_eq!(v_id, version_id); + assert_eq!(history_segment, b"history".to_vec()); + } + GetVersionResult::NoSuchVersion => panic!("should have found the version after pull"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_empty() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + assert_eq!(server.get_snapshot().await?, None); + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_roundtrip() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let version_id = Uuid::new_v4(); + let data = b"my snapshot data".to_vec(); + server.add_snapshot(version_id, data.clone()).await?; + + let result = server.get_snapshot().await?; + assert!(result.is_some(), "expected a snapshot"); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, version_id); + assert_eq!(got_data, data); + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_from_remote() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + let mut server1 = make_server_with_remote(&bare, &clone1)?; + let bare_url = bare.to_str().unwrap(); + + // server1 stores a snapshot and pushes it. + let version_id = Uuid::new_v4(); + let data = b"snapshot payload".to_vec(); + server1.add_snapshot(version_id, data.clone()).await?; + + // server2 should pull and find it. + let mut server2 = GitSyncServer::new( + clone2, + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; + let result = server2.get_snapshot().await?; + assert!(result.is_some(), "expected snapshot from remote"); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, version_id); + assert_eq!(got_data, data); + Ok(()) + } } From c104e0c184d58793a8680fcf0bf9660fedcc875a Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sun, 12 Apr 2026 13:21:18 -0700 Subject: [PATCH 07/16] fix git cleanup and misc clippy issues --- Cargo.toml | 2 +- src/server/config.rs | 5 ++++- src/server/gitsync/mod.rs | 28 ++++++++++++++-------------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fbc75b0f8..c650a119a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ encryption = ["dep:ring"] # (private) Generic support for cloud sync cloud = [] # (private) Support for Git based sync -git-sync = ["dep:serde_with", "dep:glob"] +git-sync = ["dep:serde_with", "dep:glob", "encryption"] # static bundling of dependencies bundled = ["rusqlite/bundled"] # use CA roots built into the library for all HTTPS access diff --git a/src/server/config.rs b/src/server/config.rs index a54aa2163..ae5d08d7a 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -11,7 +11,9 @@ use crate::server::cloud::CloudServer; use crate::server::local::LocalServer; #[cfg(feature = "server-sync")] use crate::server::sync::SyncServer; -use crate::{errors::Result, server::gitsync::GitSyncServer}; +#[cfg(feature = "git-sync")] +use crate::server::gitsync::GitSyncServer; +use crate::errors::Result; #[cfg(feature = "server-local")] use std::path::PathBuf; #[cfg(feature = "server-sync")] @@ -182,6 +184,7 @@ impl ServerConfig { ) .await?, ), + #[cfg(feature = "git-sync")] ServerConfig::Git { local_path, branch, diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index a33e94a56..9ab47b43f 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -14,7 +14,6 @@ use std::fs::{self, File}; use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::TryFromFloatSecsError; use uuid::Uuid; #[serde_as] @@ -43,8 +42,6 @@ struct SnapshotFile { struct Meta { #[serde(with = "uuid::serde::simple")] latest_version: VersionId, - #[serde(with = "uuid::serde::simple")] - latest_snapshot: VersionId, #[serde_as(as = "Base64")] salt: Vec, } @@ -165,7 +162,6 @@ impl GitSyncServer { let m = Meta { latest_version: Uuid::nil(), salt: Cryptor::gen_salt()?, - latest_snapshot: Uuid::nil(), }; let f = File::create_new(&meta_path)?; serde_json::to_writer(f, &m)?; @@ -176,7 +172,10 @@ impl GitSyncServer { }; // Remove any untracked files left behind by interrupted writes. - git_cmd(local_path, &["clean", "-fd"])?; + git_cmd( + local_path, + &["clean", "-f", "--", "v-*", "snapshot", "meta"], + )?; Ok(meta) } @@ -217,9 +216,11 @@ impl GitSyncServer { } git_cmd(&self.local_path, &["fetch", &self.remote, &self.branch])?; git_cmd(&self.local_path, &["reset", "--hard", "FETCH_HEAD"])?; - // Remove any untracked files so orphaned version/snapshot files from - // interrupted writes cannot be served as valid versions. - git_cmd(&self.local_path, &["clean", "-fd"])?; + // Remove any untracked files left behind by interrupted writes. + git_cmd( + &self.local_path, + &["clean", "-f", "--", "v-*", "snapshot", "meta"], + ); Ok(()) } @@ -299,6 +300,7 @@ impl Server for GitSyncServer { parent_version_id: VersionId, history_segment: HistorySegment, ) -> Result<(AddVersionResult, SnapshotUrgency)> { + // TODO: Fix snapshot urgency. // Accept any parent when the repo is empty (latest == NIL). // Otherwise check if parent is in latest. If it isn't, pull recheck. if self.meta.latest_version != Uuid::nil() && parent_version_id != self.meta.latest_version @@ -364,24 +366,21 @@ impl Server for GitSyncServer { } async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { + self.pull()?; // Write the snapshot to a file. let unsealed = Unsealed { - version_id: version_id, + version_id, payload: snapshot, }; let sealed = self.cryptor.seal(unsealed)?; let snapshot_file = SnapshotFile { - version_id: version_id, + version_id, payload: Vec::::from(sealed), }; let snapshot_path = self.local_path.join("snapshot"); let f = File::create(&snapshot_path)?; serde_json::to_writer(f, &snapshot_file)?; - // Update the meta so it contains the latest snapshot. - self.meta.latest_snapshot = version_id; - let meta_path = self.write_meta()?; - // Commit and push, reverting if push fails. self.stage_and_commit(&[&snapshot_path, &meta_path], "add snapshot")?; @@ -389,6 +388,7 @@ impl Server for GitSyncServer { // Push was rejected (non-fast-forward). Undo the commit and re-read remote state. git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; std::fs::remove_file(&snapshot_path)?; + self.pull()?; self.read_meta()?; return Err(Error::Server("Couldn't push to remote.".into())); } From ec2ae744e533c339b0e1357250b25df7282c1d15 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Sun, 12 Apr 2026 17:50:10 -0700 Subject: [PATCH 08/16] fix/log some git issues from snapshot testing --- src/server/gitsync/mod.rs | 110 ++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 9ab47b43f..ac9f4c937 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -220,7 +220,7 @@ impl GitSyncServer { git_cmd( &self.local_path, &["clean", "-f", "--", "v-*", "snapshot", "meta"], - ); + )?; Ok(()) } @@ -230,12 +230,17 @@ impl GitSyncServer { if self.local_only { return Ok(true); } - let status = Command::new("git") + let output = Command::new("git") .args(["push", &self.remote, &self.branch]) .current_dir(&self.local_path) - .stderr(std::process::Stdio::null()) - .status()?; - Ok(status.success()) + .output()?; + if !output.status.success() { + log::debug!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output.status.success()) } /// Encrypt and write a version file. Returns the file path. @@ -255,7 +260,7 @@ impl GitSyncServer { Ok(path) } - /// Read and decrypt a version file. Returns a Version if found, None if not. + /// Read and decrypt a version file. Returns a Version if found, None if not, Err on filesystem/encryption errors. fn get_version_by_parent_version_id(&self, version: &VersionId) -> Result> { // glob to find file. // v-PARENT-CHILD @@ -266,31 +271,65 @@ impl GitSyncServer { .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?, version.simple() ); + for entry in glob(&pattern).map_err(|e| Error::Server(format!("{:?}", e)))? { - let result = (|| -> Option { - let path = entry.ok()?; - let mut buf = Vec::new(); - File::open(&path).ok()?.read_to_end(&mut buf).ok()?; - let filename = path.to_str()?; - let (_, version_id_str) = filename.rsplit_once('-')?; - let version_id = Uuid::parse_str(version_id_str).ok()?; - let sealed = Sealed { - version_id, - payload: buf, - }; - let unsealed = self.cryptor.unseal(sealed).ok()?; - Some(Version { - version_id: unsealed.version_id, - parent_version_id: *version, - history_segment: unsealed.payload, - }) - })(); - if let Some(v) = result { - return Ok(Some(v)); - } + let path = match entry { + Ok(p) => p, + Err(e) => { + log::warn!("glob entry error: {e}"); + continue; + } + }; + let filename = match path.to_str() { + Some(f) => f, + None => { + log::warn!("non-UTF-8 path, skipping"); + continue; + } + }; + let (_, version_id_str) = match filename.rsplit_once('-') { + Some(parts) => parts, + None => { + log::warn!("unexpected filename format: {filename}"); + continue; + } + }; + let version_id = match Uuid::parse_str(version_id_str) { + Ok(id) => id, + Err(e) => { + log::warn!("bad version id in {filename}: {e}"); + continue; + } + }; + // Real errors past this point + let mut buf = Vec::new(); + File::open(&path)?.read_to_end(&mut buf)?; + let sealed = Sealed { + version_id, + payload: buf, + }; + let unsealed = self.cryptor.unseal(sealed)?; + return Ok(Some(Version { + version_id: unsealed.version_id, + parent_version_id: *version, + history_segment: unsealed.payload, + })); } Ok(None) } + + /// Determine snapshot urgency. + /// + /// In general, the more files there are, the slower many of the sync functions will be. + fn snapshot_urgency(&self) -> SnapshotUrgency { + let pattern = format!("{}/v-*", self.local_path.display()); + let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); + match count { + 0..=10 => SnapshotUrgency::None, + 11..=50 => SnapshotUrgency::Low, + _ => SnapshotUrgency::High, + } + } } #[async_trait(?Send)] @@ -341,7 +380,7 @@ impl Server for GitSyncServer { )); } - Ok((AddVersionResult::Ok(version_id), SnapshotUrgency::None)) + Ok((AddVersionResult::Ok(version_id), self.snapshot_urgency())) } async fn get_child_version( @@ -366,7 +405,9 @@ impl Server for GitSyncServer { } async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { - self.pull()?; + if self.meta.latest_version != Uuid::nil() { + self.pull()?; + } // Write the snapshot to a file. let unsealed = Unsealed { version_id, @@ -382,16 +423,19 @@ impl Server for GitSyncServer { serde_json::to_writer(f, &snapshot_file)?; // Commit and push, reverting if push fails. - self.stage_and_commit(&[&snapshot_path, &meta_path], "add snapshot")?; + self.stage_and_commit(&[&snapshot_path, &snapshot_path], "add snapshot")?; if !self.push()? { - // Push was rejected (non-fast-forward). Undo the commit and re-read remote state. + // Push was rejected. git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; std::fs::remove_file(&snapshot_path)?; self.pull()?; self.read_meta()?; return Err(Error::Server("Couldn't push to remote.".into())); } + + // TODO: Cleanup old files after a sucessful snapshot. + // Ok(()) } @@ -749,15 +793,13 @@ mod test { let mut server1 = make_server_with_remote(&bare, &clone1)?; let bare_url = bare.to_str().unwrap(); - // server1 stores a snapshot and pushes it. let version_id = Uuid::new_v4(); let data = b"snapshot payload".to_vec(); server1.add_snapshot(version_id, data.clone()).await?; - // server2 should pull and find it. let mut server2 = GitSyncServer::new( - clone2, + clone2.clone(), "main".into(), bare_url.into(), false, From 94fbbd4c78c5c93fd2688d75c886ccf0bbe0cd47 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Mon, 13 Apr 2026 17:31:23 -0700 Subject: [PATCH 09/16] fix snapshot encryption --- src/server/gitsync/mod.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index ac9f4c937..faac4fc7d 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -441,18 +441,17 @@ impl Server for GitSyncServer { async fn get_snapshot(&mut self) -> Result> { self.pull()?; + let snapshot_path = self.local_path.join("snapshot"); if let Ok(file) = File::open(&snapshot_path) { - let reader = BufReader::new(file); - let s: SnapshotFile = serde_json::from_reader(reader)?; - let sealed = Sealed { + let s: SnapshotFile = serde_json::from_reader(BufReader::new(file))?; + let unsealed = self.cryptor.unseal(Sealed { version_id: s.version_id, payload: s.payload, - }; - let unsealed = self.cryptor.unseal(sealed)?; - return Ok(Some((unsealed.version_id, unsealed.payload))); + })?; + Ok(Some((unsealed.version_id, unsealed.payload))) } else { - return Ok(None); + Ok(None) } } } From 9b2818a9fa3db8f12abe2f5dfee206c46ec7afc8 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Mon, 13 Apr 2026 20:07:10 -0700 Subject: [PATCH 10/16] add some documentation --- src/server/config.rs | 2 ++ src/server/gitsync/mod.rs | 69 ++++++++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/server/config.rs b/src/server/config.rs index ae5d08d7a..844c8bb5d 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -16,6 +16,8 @@ use crate::server::gitsync::GitSyncServer; use crate::errors::Result; #[cfg(feature = "server-local")] use std::path::PathBuf; +#[cfg(all(feature = "git-sync", not(feature = "server-local")))] +use std::path::PathBuf; #[cfg(feature = "server-sync")] use uuid::Uuid; diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index faac4fc7d..24db34119 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -16,6 +16,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use uuid::Uuid; +/// A version record stored in a version file on disk. #[serde_as] #[derive(Serialize, Deserialize, Debug)] struct Version { @@ -27,6 +28,9 @@ struct Version { history_segment: HistorySegment, } +/// The snapshot record stored in the `snapshot` file on disk. +/// +/// It is overwritten when a newer snapshot is added #[serde_as] #[derive(Serialize, Deserialize, Debug)] struct SnapshotFile { @@ -36,7 +40,7 @@ struct SnapshotFile { payload: Vec, } -/// The meta file holds the UUID of the most recent Version and the salt. +/// Repository metadata. #[serde_as] #[derive(Serialize, Deserialize, Debug)] struct Meta { @@ -46,6 +50,12 @@ struct Meta { salt: Vec, } +/// A [`Server`] backed by a local git repository. +/// +/// +/// When `local_only` is `false` the server pushes to and pulls from `remote` on the `branch` +/// branch after each write. Conflict resolution is handled as: commit locally, +/// attempt push, and on rejection pull-and-reset before returning. pub(crate) struct GitSyncServer { meta: Meta, local_path: PathBuf, @@ -69,6 +79,12 @@ fn git_cmd(dir: &Path, args: &[&str]) -> Result<()> { } impl GitSyncServer { + /// Create (or re-open) a git-backed sync server. + /// + /// If `local_path` does not yet exist it is created. If it exists but is not a git + /// repository: + /// - In `local_only` mode a new repo is initialised there. + /// - In remote mode the repo is cloned from `remote`. pub(crate) fn new( local_path: PathBuf, branch: String, @@ -89,6 +105,10 @@ impl GitSyncServer { Ok(server) } + /// Initialise or open the git repository and return the current [`Meta`]. + /// + /// Creates the directory, initialises or clones the repo, switches to `branch`, and + /// creates the `meta` file on first run. fn init_repo(local_path: &Path, branch: &str, remote: &str, local_only: bool) -> Result { // Create the local directory if needed. fs::create_dir_all(local_path)?; @@ -101,6 +121,7 @@ impl GitSyncServer { .status .success(); + // Create one if not. if !is_repo { if local_only { info!("Creating new repo at {:?}", local_path); @@ -210,10 +231,28 @@ impl GitSyncServer { } /// Fetch and fast-forward to the remote branch. No-op in local-only mode. + /// If the remote branch does not yet exist (e.g. fresh bare repo), this is also a no-op. fn pull(&self) -> Result<()> { if self.local_only { return Ok(()); } + // Check whether the remote branch exists before fetching. A bare repo with no commits + // has no refs yet, and `git fetch origin ` would fail in that case. + let has_remote_branch = Command::new("git") + .args([ + "ls-remote", + "--exit-code", + "--heads", + &self.remote, + &self.branch, + ]) + .current_dir(&self.local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if !has_remote_branch { + return Ok(()); + } git_cmd(&self.local_path, &["fetch", &self.remote, &self.branch])?; git_cmd(&self.local_path, &["reset", "--hard", "FETCH_HEAD"])?; // Remove any untracked files left behind by interrupted writes. @@ -243,7 +282,9 @@ impl GitSyncServer { Ok(output.status.success()) } - /// Encrypt and write a version file. Returns the file path. + /// Encrypt and write a version file named `v-{parent_version_id}-{version_id}`. + /// + /// Returns the path of the newly written file. fn add_version_by_parent_version_id(&self, version: &Version) -> Result { let unsealed = Unsealed { version_id: version.version_id, @@ -260,7 +301,7 @@ impl GitSyncServer { Ok(path) } - /// Read and decrypt a version file. Returns a Version if found, None if not, Err on filesystem/encryption errors. + /// Find, read, and decrypt the version file whose parent matches `version`. fn get_version_by_parent_version_id(&self, version: &VersionId) -> Result> { // glob to find file. // v-PARENT-CHILD @@ -318,9 +359,10 @@ impl GitSyncServer { Ok(None) } - /// Determine snapshot urgency. + /// Return the appropriate [`SnapshotUrgency`] based on the number of uncommitted version files. /// - /// In general, the more files there are, the slower many of the sync functions will be. + /// More version files means more work for [`get_child_version`] callers walking the chain, + /// so urgency rises with the file count. fn snapshot_urgency(&self) -> SnapshotUrgency { let pattern = format!("{}/v-*", self.local_path.display()); let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); @@ -339,7 +381,6 @@ impl Server for GitSyncServer { parent_version_id: VersionId, history_segment: HistorySegment, ) -> Result<(AddVersionResult, SnapshotUrgency)> { - // TODO: Fix snapshot urgency. // Accept any parent when the repo is empty (latest == NIL). // Otherwise check if parent is in latest. If it isn't, pull recheck. if self.meta.latest_version != Uuid::nil() && parent_version_id != self.meta.latest_version @@ -405,9 +446,7 @@ impl Server for GitSyncServer { } async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { - if self.meta.latest_version != Uuid::nil() { - self.pull()?; - } + self.pull()?; // Write the snapshot to a file. let unsealed = Unsealed { version_id, @@ -423,7 +462,7 @@ impl Server for GitSyncServer { serde_json::to_writer(f, &snapshot_file)?; // Commit and push, reverting if push fails. - self.stage_and_commit(&[&snapshot_path, &snapshot_path], "add snapshot")?; + self.stage_and_commit(&[&snapshot_path], "add snapshot")?; if !self.push()? { // Push was rejected. @@ -434,8 +473,10 @@ impl Server for GitSyncServer { return Err(Error::Server("Couldn't push to remote.".into())); } - // TODO: Cleanup old files after a sucessful snapshot. - // + // TODO: After a successful snapshot, delete all superseded version files (v-* files + // whose child version ID predates the snapshot version). The cloud server does this + // in its cleanup() method. Without cleanup, version files accumulate indefinitely + // and snapshot_urgency() will keep returning High even after a snapshot is stored. Ok(()) } @@ -501,8 +542,6 @@ mod test { let server = make_server(tmp.path())?; assert!(tmp.path().join("meta").exists()); assert_eq!(server.meta.latest_version, Uuid::nil()); - // eprintln!("tmp dir: {}", tmp.path().display()); - // std::mem::forget(tmp); Ok(()) } @@ -566,8 +605,6 @@ mod test { )?; server2.pull()?; assert!(clone2.join("testfile").exists()); - // eprintln!("tmp dir: {}", tmp.path().display()); - // std::mem::forget(tmp); Ok(()) } From d40262f1cab3601e88f57641b709bb755a9f9bc1 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 14 Apr 2026 09:04:51 -0700 Subject: [PATCH 11/16] Add more snapshot tests --- src/server/gitsync/mod.rs | 109 +++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 24db34119..539e751f0 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -366,9 +366,10 @@ impl GitSyncServer { fn snapshot_urgency(&self) -> SnapshotUrgency { let pattern = format!("{}/v-*", self.local_path.display()); let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); + // For reviewers: Is this reasonable? match count { - 0..=10 => SnapshotUrgency::None, - 11..=50 => SnapshotUrgency::Low, + 0..=50 => SnapshotUrgency::None, + 51..=100 => SnapshotUrgency::Low, _ => SnapshotUrgency::High, } } @@ -848,4 +849,108 @@ mod test { assert_eq!(got_data, data); Ok(()) } + + /// A second `add_snapshot` should overwrite the first: only the latest snapshot is + /// returned by `get_snapshot`. + #[tokio::test] + async fn test_snapshot_overwrite() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let v1 = Uuid::new_v4(); + server.add_snapshot(v1, b"first snapshot".to_vec()).await?; + + let v2 = Uuid::new_v4(); + server.add_snapshot(v2, b"second snapshot".to_vec()).await?; + + let result = server.get_snapshot().await?; + assert!(result.is_some()); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, v2, "expected the second snapshot's version_id"); + assert_eq!(got_data, b"second snapshot".to_vec()); + Ok(()) + } + + /// `add_snapshot` push-failure rollback: install a pre-receive hook on the bare repo that + /// rejects all pushes (while still allowing fetches), forcing a push rejection. + /// After the Err, the working tree must be clean and the snapshot file must be absent. + #[tokio::test] + async fn test_snapshot_push_rejected_rollback() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + + let mut server = make_server_with_remote(&bare, &clone1)?; + + // Add a version so the remote branch exists (required for pull's ls-remote check). + server.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + + // Install a pre-receive hook that rejects all pushes. + // Fetches still work because hooks only run on the receive side. + let hooks_dir = bare.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + let hook = hooks_dir.join("pre-receive"); + std::fs::write(&hook, b"#!/bin/sh\nexit 1\n")?; + // Make the hook executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?; + } + + // add_snapshot should pull successfully then fail to push (hook rejects it). + let v1 = Uuid::new_v4(); + let result = server.add_snapshot(v1, b"snap".to_vec()).await; + assert!( + result.is_err(), + "expected Err when push is rejected by hook" + ); + + // The snapshot file must not remain in the working tree. + assert!( + !server.local_path.join("snapshot").exists(), + "snapshot file should be removed after rollback" + ); + + // The git index must be clean (no staged changes left over). + let status = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&server.local_path) + .output()?; + assert!( + status.stdout.is_empty(), + "git index should be clean after rollback, got: {}", + String::from_utf8_lossy(&status.stdout) + ); + + Ok(()) + } + + /// A corrupted version file should return `Err`. + #[tokio::test] + async fn test_get_child_version_corrupted_file() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + // Add a real version so we know the parent UUID. + let parent = Uuid::new_v4(); + let (result, _) = server.add_version(parent, b"good data".to_vec()).await?; + let AddVersionResult::Ok(child) = result else { + panic!("add_version failed"); + }; + + // Overwrite the version file with garbage to simulate corruption. + let filename = format!("v-{}-{}", parent.simple(), child.simple()); + std::fs::write(tmp.path().join(&filename), b"this is not valid ciphertext")?; + + // get_child_version should return Err (decryption failure), not NoSuchVersion. + let result = server.get_child_version(parent).await; + assert!( + result.is_err(), + "expected Err on decryption failure, got: {:?}", + result + ); + + Ok(()) + } } From e60303054cd30af646da0a75d58ec77dcf28abeb Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 17 Apr 2026 09:10:57 -0700 Subject: [PATCH 12/16] add cleanup after snapshot --- src/server/gitsync/mod.rs | 182 ++++++++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 28 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 539e751f0..990f6d81d 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -1,3 +1,4 @@ +//! TODO: Add overall documentation use crate::errors::Result; use crate::server::encryption::{Cryptor, Sealed, Unsealed}; use crate::server::{ @@ -8,6 +9,7 @@ use crate::Error; use async_trait::async_trait; use glob::glob; use log::info; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64, serde_as}; use std::fs::{self, File}; @@ -79,7 +81,7 @@ fn git_cmd(dir: &Path, args: &[&str]) -> Result<()> { } impl GitSyncServer { - /// Create (or re-open) a git-backed sync server. + /// Create or re-open a git-backed sync server. /// /// If `local_path` does not yet exist it is created. If it exists but is not a git /// repository: @@ -361,18 +363,136 @@ impl GitSyncServer { /// Return the appropriate [`SnapshotUrgency`] based on the number of uncommitted version files. /// - /// More version files means more work for [`get_child_version`] callers walking the chain, - /// so urgency rises with the file count. + /// Returns `None` if a snapshot already exists (cleanup will have removed covered files, + /// so a non-zero count reflects only post-snapshot versions). Otherwise urgency rises + /// with the file count. fn snapshot_urgency(&self) -> SnapshotUrgency { + // If a snapshot already exists, cleanup has (or will) remove covered version files. + // Don't request another snapshot until enough new versions accumulate. + if self.local_path.join("snapshot").exists() { + let pattern = format!("{}/v-*", self.local_path.display()); + let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); + return match count { + 0..=50 => SnapshotUrgency::None, + 51..=100 => SnapshotUrgency::Low, + _ => SnapshotUrgency::High, + }; + } let pattern = format!("{}/v-*", self.local_path.display()); let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); - // For reviewers: Is this reasonable? + // TODO: Performance test get_version_by_parent_version_id to help determine + // what a reasonable number is. match count { 0..=50 => SnapshotUrgency::None, 51..=100 => SnapshotUrgency::Low, _ => SnapshotUrgency::High, } } + + /// Cleans up the repository by removing version files covered by the current snapshot. + /// + /// Reads the snapshot to determine which version it covers, then walks backward + /// through the version chain from that version. All version files on that chain + /// (i.e. all history that is now redundant given the snapshot) are deleted, + /// committed, and pushed. If the push is rejected, we reset to the remote state + /// and will retry on the next snapshot. + /// + /// This is a best-effort operation: if called with no snapshot present it is a no-op. + fn cleanup(&self) -> Result<()> { + // Read snapshot metadata. The version_id is stored unencrypted in the JSON wrapper, + // so no decryption is needed here. + let snapshot_path = self.local_path.join("snapshot"); + let snapshot_version: Uuid = if let Ok(file) = File::open(&snapshot_path) { + let s: SnapshotFile = serde_json::from_reader(BufReader::new(file))?; + s.version_id + } else { + return Ok(()); + }; + + // Parse all v-* filenames into a child → (parent, path) map. + let pattern = format!( + "{}/v-*", + self.local_path + .to_str() + .ok_or_else(|| Error::Server("repo path is not valid UTF-8".into()))? + ); + let mut versions: HashMap = HashMap::new(); + for entry in glob(&pattern).map_err(|e| Error::Server(format!("{e:?}")))? { + let path = match entry { + Ok(p) => p, + Err(e) => { + log::warn!("cleanup: glob error: {e}"); + continue; + } + }; + let Some(filename) = path.file_name().and_then(|f| f.to_str()) else { + continue; + }; + // Filename format: v-{parent_simple}-{child_simple} + let Some(stem) = filename.strip_prefix("v-") else { + continue; + }; + let Some((parent_str, child_str)) = stem.split_once('-') else { + continue; + }; + let (Ok(parent_id), Ok(child_id)) = + (Uuid::parse_str(parent_str), Uuid::parse_str(child_str)) + else { + continue; + }; + versions.insert(child_id, (parent_id, path)); + } + + if versions.is_empty() { + return Ok(()); + } + + // Walk the chain backward from snapshot_version to find all versions whose + // history is now captured by the snapshot. + let mut covered: HashSet = HashSet::new(); + let mut current = snapshot_version; + for _ in 0..=versions.len() { + if !covered.insert(current) { + log::warn!("cleanup: version cycle detected, aborting"); + return Ok(()); + } + match versions.get(¤t) { + Some((parent, _)) => current = *parent, + None => break, + } + } + + // Remove each covered version file from the git index and working tree. + let mut any_removed = false; + for (child_id, (_, path)) in &versions { + if covered.contains(child_id) { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| Error::Server("version file path is not valid UTF-8".into()))?; + git_cmd(&self.local_path, &["rm", name])?; + any_removed = true; + } + } + + if !any_removed { + return Ok(()); + } + + git_cmd( + &self.local_path, + &["commit", "-m", "cleanup: remove version files covered by snapshot"], + )?; + + // Best-effort push. A rejected push just means another replica will clean up + // later (or we will on the next snapshot). Reset to remote state so we are in sync. + if !self.push()? { + log::info!("cleanup: push rejected, resetting to remote state"); + self.pull()?; + } + + Ok(()) + } } #[async_trait(?Send)] @@ -474,10 +594,8 @@ impl Server for GitSyncServer { return Err(Error::Server("Couldn't push to remote.".into())); } - // TODO: After a successful snapshot, delete all superseded version files (v-* files - // whose child version ID predates the snapshot version). The cloud server does this - // in its cleanup() method. Without cleanup, version files accumulate indefinitely - // and snapshot_urgency() will keep returning High even after a snapshot is stored. + // Cleanup: delete all version files covered by this snapshot, then commit and push. + self.cleanup()?; Ok(()) } @@ -926,31 +1044,39 @@ mod test { Ok(()) } - /// A corrupted version file should return `Err`. + /// After `add_snapshot`, all version files covered by the snapshot should be deleted + /// (cleanup is called automatically by `add_snapshot`). #[tokio::test] - async fn test_get_child_version_corrupted_file() -> Result<()> { + async fn test_cleanup_removes_version_files() -> Result<()> { let tmp = TempDir::new()?; let mut server = make_server(tmp.path())?; - // Add a real version so we know the parent UUID. - let parent = Uuid::new_v4(); - let (result, _) = server.add_version(parent, b"good data".to_vec()).await?; - let AddVersionResult::Ok(child) = result else { - panic!("add_version failed"); + // Add a chain of three versions. + let (r1, _) = server.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + let AddVersionResult::Ok(v1) = r1 else { + panic!("add_version 1 failed"); }; - - // Overwrite the version file with garbage to simulate corruption. - let filename = format!("v-{}-{}", parent.simple(), child.simple()); - std::fs::write(tmp.path().join(&filename), b"this is not valid ciphertext")?; - - // get_child_version should return Err (decryption failure), not NoSuchVersion. - let result = server.get_child_version(parent).await; - assert!( - result.is_err(), - "expected Err on decryption failure, got: {:?}", - result - ); - + let (r2, _) = server.add_version(v1, b"v2".to_vec()).await?; + let AddVersionResult::Ok(v2) = r2 else { + panic!("add_version 2 failed"); + }; + let (r3, _) = server.add_version(v2, b"v3".to_vec()).await?; + let AddVersionResult::Ok(v3) = r3 else { + panic!("add_version 3 failed"); + }; + // Confirm they exist. + assert!(tmp.path().join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())).exists()); + assert!(tmp.path().join(format!("v-{}-{}", v1.simple(), v2.simple())).exists()); + assert!(tmp.path().join(format!("v-{}-{}", v2.simple(), v3.simple())).exists()); + + // Snapshot at v3; cleanup should remove all three version files. + server.add_snapshot(v3, b"full state".to_vec()).await?; + + assert!(!tmp.path().join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())).exists(), "v1 file should be gone"); + assert!(!tmp.path().join(format!("v-{}-{}", v1.simple(), v2.simple())).exists(), "v2 file should be gone"); + assert!(!tmp.path().join(format!("v-{}-{}", v2.simple(), v3.simple())).exists(), "v3 file should be gone"); + // The snapshot file itself must still exist. + assert!(tmp.path().join("snapshot").exists(), "snapshot file should remain"); Ok(()) } } From 7fc7ecf0c97691eb4aff9d42f050e2d66b1a1eeb Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 17 Apr 2026 09:16:08 -0700 Subject: [PATCH 13/16] add documentation --- src/server/gitsync/mod.rs | 75 ++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 990f6d81d..aed856d79 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -1,4 +1,25 @@ -//! TODO: Add overall documentation +//! Git-backed sync server for TaskChampion. +//! +//! [`GitSyncServer`] implements the [`Server`] trait using a local git repository as its +//! backing store, with optional push/pull to a remote (e.g. a bare repo on a shared +//! filesystem or a remote accessed via SSH). +//! +//! Each sync operation is represented as a commit on a single branch: +//! +//! - **Versions** are stored as files named `v-{parent_uuid}-{child_uuid}`, containing +//! encrypted [`HistorySegment`] bytes. +//! - **Snapshots** are stored as a single file named `snapshot`, containing a JSON wrapper +//! around an encrypted full-state blob. +//! - **Metadata** (`meta`) holds the latest version UUID and the encryption salt as JSON. +//! +//! After each write (`add_version`, `add_snapshot`) the server stages the changed files, +//! creates a commit, and pushes to the remote. If the push is rejected , thecommit is +//! rolled back and the caller receives an [`AddVersionResult::ExpectedParentVersion`] +//! or an [`Error`] so it can retry. +//! +//! After a snapshot is stored, [`GitSyncServer::cleanup`] automatically removes all +//! version files whose history is now captured by the snapshot, keeping the repository +//! compact. use crate::errors::Result; use crate::server::encryption::{Cryptor, Sealed, Unsealed}; use crate::server::{ @@ -9,9 +30,9 @@ use crate::Error; use async_trait::async_trait; use glob::glob; use log::info; -use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64, serde_as}; +use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; @@ -380,8 +401,7 @@ impl GitSyncServer { } let pattern = format!("{}/v-*", self.local_path.display()); let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); - // TODO: Performance test get_version_by_parent_version_id to help determine - // what a reasonable number is. + // For reviewers: does this seem reasonable? match count { 0..=50 => SnapshotUrgency::None, 51..=100 => SnapshotUrgency::Low, @@ -481,7 +501,11 @@ impl GitSyncServer { git_cmd( &self.local_path, - &["commit", "-m", "cleanup: remove version files covered by snapshot"], + &[ + "commit", + "-m", + "cleanup: remove version files covered by snapshot", + ], )?; // Best-effort push. A rejected push just means another replica will clean up @@ -1065,18 +1089,45 @@ mod test { panic!("add_version 3 failed"); }; // Confirm they exist. - assert!(tmp.path().join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())).exists()); - assert!(tmp.path().join(format!("v-{}-{}", v1.simple(), v2.simple())).exists()); - assert!(tmp.path().join(format!("v-{}-{}", v2.simple(), v3.simple())).exists()); + assert!(tmp + .path() + .join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())) + .exists()); + assert!(tmp + .path() + .join(format!("v-{}-{}", v1.simple(), v2.simple())) + .exists()); + assert!(tmp + .path() + .join(format!("v-{}-{}", v2.simple(), v3.simple())) + .exists()); // Snapshot at v3; cleanup should remove all three version files. server.add_snapshot(v3, b"full state".to_vec()).await?; - assert!(!tmp.path().join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())).exists(), "v1 file should be gone"); - assert!(!tmp.path().join(format!("v-{}-{}", v1.simple(), v2.simple())).exists(), "v2 file should be gone"); - assert!(!tmp.path().join(format!("v-{}-{}", v2.simple(), v3.simple())).exists(), "v3 file should be gone"); + assert!( + !tmp.path() + .join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())) + .exists(), + "v1 file should be gone" + ); + assert!( + !tmp.path() + .join(format!("v-{}-{}", v1.simple(), v2.simple())) + .exists(), + "v2 file should be gone" + ); + assert!( + !tmp.path() + .join(format!("v-{}-{}", v2.simple(), v3.simple())) + .exists(), + "v3 file should be gone" + ); // The snapshot file itself must still exist. - assert!(tmp.path().join("snapshot").exists(), "snapshot file should remain"); + assert!( + tmp.path().join("snapshot").exists(), + "snapshot file should remain" + ); Ok(()) } } From fd09e02527de8c58499acb03869066a5008d2f59 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 17 Apr 2026 20:43:53 -0700 Subject: [PATCH 14/16] cleanup of snapshot code, add documentation --- src/server/gitsync/mod.rs | 182 ++++++++++++++++++++++---------------- 1 file changed, 107 insertions(+), 75 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index aed856d79..5362a88bb 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -1,16 +1,13 @@ //! Git-backed sync server for TaskChampion. //! //! [`GitSyncServer`] implements the [`Server`] trait using a local git repository as its -//! backing store, with optional push/pull to a remote (e.g. a bare repo on a shared -//! filesystem or a remote accessed via SSH). +//! backing store, with optional push/pull to a remote. //! -//! Each sync operation is represented as a commit on a single branch: -//! -//! - **Versions** are stored as files named `v-{parent_uuid}-{child_uuid}`, containing +//! - Versions are stored as files named `v-{parent_uuid}-{child_uuid}`, containing //! encrypted [`HistorySegment`] bytes. -//! - **Snapshots** are stored as a single file named `snapshot`, containing a JSON wrapper +//! - Snapshots are stored as a single file named `snapshot`, containing a JSON wrapper //! around an encrypted full-state blob. -//! - **Metadata** (`meta`) holds the latest version UUID and the encryption salt as JSON. +//! - Metadata (`meta`) holds the latest version UUID and the encryption salt as JSON. //! //! After each write (`add_version`, `add_snapshot`) the server stages the changed files, //! creates a commit, and pushes to the remote. If the push is rejected , thecommit is @@ -20,6 +17,27 @@ //! After a snapshot is stored, [`GitSyncServer::cleanup`] automatically removes all //! version files whose history is now captured by the snapshot, keeping the repository //! compact. +//! +//! Notes and Expectations +//! +//! - Since this shells out to git, it assumes that you havea reasonably functional git +//! setup. I.e. 'git init', 'git add', 'git commit', etc shoud just work. +//! - If you are using a remote, 'git push' and 'git pull' shoud work. +//! - Due to the nature of the version and snapshot history, you probably shouldn't do +//! a lot of the things you normally would with a git repo, like merge, squash, etc. +//! Just let TaskChampion manage it. +//! - If you are planning on using it for other things, it is HIGHLY recommended that you +//! create a 'task' branch and let TaskChampion manage that branch. +//! - This does support both defining a remote and having `local_only` mode set at the same +//! time. The idea is that maybe the remote isn't ready yet, or eithe rtemporarily or +//! permanantly down. Either way, you can use this in local mode in the mean time. +//! - Remember, a remote can be on the same machine as local. This is used for testing. +//! +//! Notes for Reviewers +//! +//! - I haven't done any performance testing, but it seems reasonably quick for manual use. +//! - Currently is uses the same salt for all files. This isn't great security practice, +//! but does seem to be what the other servers are doing. use crate::errors::Result; use crate::server::encryption::{Cryptor, Sealed, Unsealed}; use crate::server::{ @@ -324,8 +342,11 @@ impl GitSyncServer { Ok(path) } - /// Find, read, and decrypt the version file whose parent matches `version`. - fn get_version_by_parent_version_id(&self, version: &VersionId) -> Result> { + /// Find, read, and decrypt the version file whose parent matches `parent_version_id`. + fn get_version_by_parent_version_id( + &self, + parent_version_id: &VersionId, + ) -> Result> { // glob to find file. // v-PARENT-CHILD let pattern = format!( @@ -333,7 +354,7 @@ impl GitSyncServer { self.local_path .to_str() .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?, - version.simple() + parent_version_id.simple() ); for entry in glob(&pattern).map_err(|e| Error::Server(format!("{:?}", e)))? { @@ -375,33 +396,23 @@ impl GitSyncServer { let unsealed = self.cryptor.unseal(sealed)?; return Ok(Some(Version { version_id: unsealed.version_id, - parent_version_id: *version, + parent_version_id: *parent_version_id, history_segment: unsealed.payload, })); } Ok(None) } - /// Return the appropriate [`SnapshotUrgency`] based on the number of uncommitted version files. + /// Return the [`SnapshotUrgency`] based on the number of version files present. /// - /// Returns `None` if a snapshot already exists (cleanup will have removed covered files, - /// so a non-zero count reflects only post-snapshot versions). Otherwise urgency rises - /// with the file count. + /// Since cleanup runs after every successful add_snapshot and removes + /// all version files covered by the snapshot, the count here reflects only post-snapshot + /// versions. fn snapshot_urgency(&self) -> SnapshotUrgency { - // If a snapshot already exists, cleanup has (or will) remove covered version files. - // Don't request another snapshot until enough new versions accumulate. - if self.local_path.join("snapshot").exists() { - let pattern = format!("{}/v-*", self.local_path.display()); - let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); - return match count { - 0..=50 => SnapshotUrgency::None, - 51..=100 => SnapshotUrgency::Low, - _ => SnapshotUrgency::High, - }; - } let pattern = format!("{}/v-*", self.local_path.display()); let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); - // For reviewers: does this seem reasonable? + // TODO: Performance test get_version_by_parent_version_id to help determine + // what reasonable thresholds are. match count { 0..=50 => SnapshotUrgency::None, 51..=100 => SnapshotUrgency::Low, @@ -413,11 +424,10 @@ impl GitSyncServer { /// /// Reads the snapshot to determine which version it covers, then walks backward /// through the version chain from that version. All version files on that chain - /// (i.e. all history that is now redundant given the snapshot) are deleted, - /// committed, and pushed. If the push is rejected, we reset to the remote state + /// are deleted, committed, and pushed. If the push is rejected, we reset to the remote state /// and will retry on the next snapshot. /// - /// This is a best-effort operation: if called with no snapshot present it is a no-op. + /// If called with no snapshot present it is a no-op. fn cleanup(&self) -> Result<()> { // Read snapshot metadata. The version_id is stored unencrypted in the JSON wrapper, // so no decryption is needed here. @@ -429,7 +439,7 @@ impl GitSyncServer { return Ok(()); }; - // Parse all v-* filenames into a child → (parent, path) map. + // Parse all v-* filenames into a child -> (parent, path) map. let pattern = format!( "{}/v-*", self.local_path @@ -483,16 +493,24 @@ impl GitSyncServer { } // Remove each covered version file from the git index and working tree. + // If any `git rm` fails partway through we `git reset HEAD` to unstage, leaving + // the working tree dirty but the index clean for subsequent operations. let mut any_removed = false; - for (child_id, (_, path)) in &versions { - if covered.contains(child_id) { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| Error::Server("version file path is not valid UTF-8".into()))?; - git_cmd(&self.local_path, &["rm", name])?; - any_removed = true; + let rm_result: Result<()> = (|| { + for (child_id, (_, path)) in &versions { + if covered.contains(child_id) { + let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + Error::Server("version file path is not valid UTF-8".into()) + })?; + git_cmd(&self.local_path, &["rm", name])?; + any_removed = true; + } } + Ok(()) + })(); + if let Err(e) = rm_result { + let _ = git_cmd(&self.local_path, &["reset", "HEAD"]); + return Err(e); } if !any_removed { @@ -508,8 +526,8 @@ impl GitSyncServer { ], )?; - // Best-effort push. A rejected push just means another replica will clean up - // later (or we will on the next snapshot). Reset to remote state so we are in sync. + // A rejected push means another replica will clean up later, or we will + // on the next snapshot. Reset to remote state so we are in sync. if !self.push()? { log::info!("cleanup: push rejected, resetting to remote state"); self.pull()?; @@ -555,7 +573,7 @@ impl Server for GitSyncServer { self.stage_and_commit(&[&version_path, &meta_path], "add version")?; if !self.push()? { - // Push was rejected (non-fast-forward). Undo the commit and re-read remote state. + // Push was rejected, reset and re-read state. git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; std::fs::remove_file(&version_path)?; self.pull()?; @@ -593,6 +611,10 @@ impl Server for GitSyncServer { async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { self.pull()?; // Write the snapshot to a file. + // Note: If another replica has pushed a snapshot for a + // later version in the chain between our pull and our push, we will overwrite it. + // This should be harmless: a replica with newer state will overwrite again, + // and push rejection handles concurrent writes. let unsealed = Unsealed { version_id, payload: snapshot, @@ -610,16 +632,18 @@ impl Server for GitSyncServer { self.stage_and_commit(&[&snapshot_path], "add snapshot")?; if !self.push()? { - // Push was rejected. + // Push was rejected. Undo the commit, pull and clean to restore state. git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; - std::fs::remove_file(&snapshot_path)?; self.pull()?; self.read_meta()?; return Err(Error::Server("Couldn't push to remote.".into())); } - // Cleanup: delete all version files covered by this snapshot, then commit and push. - self.cleanup()?; + // Cleanup is best-effort: the snapshot is already safely stored on the remote. + // A cleanup failure just means version files will linger until the next successful snapshot. + if let Err(e) = self.cleanup() { + log::warn!("snapshot stored but cleanup failed: {e}"); + } Ok(()) } @@ -774,25 +798,6 @@ mod test { Ok(()) } - #[tokio::test] - async fn test_add_nonzero_base() -> Result<()> { - let tmp = TempDir::new()?; - let mut server = make_server(tmp.path())?; - let history = b"1234".to_vec(); - let parent_version_id = Uuid::new_v4(); - - // OK because latest == NIL (repo is empty). - match server.add_version(parent_version_id, history).await?.0 { - AddVersionResult::ExpectedParentVersion(_) => { - panic!("should have accepted the version") - } - AddVersionResult::Ok(version_id) => { - assert_eq!(server.meta.latest_version, version_id); - } - } - Ok(()) - } - #[tokio::test] async fn test_add_nonzero_base_forbidden() -> Result<()> { let tmp = TempDir::new()?; @@ -859,27 +864,26 @@ mod test { } #[tokio::test] - async fn get_child_version() -> Result<()> { + async fn test_get_child_version_local() -> Result<()> { let tmp = TempDir::new()?; let mut server = make_server(tmp.path())?; - let history = b"1234".to_vec(); let parent_version_id = Uuid::new_v4(); - // Version doesn't exist yet + // With no versions written, lookup returns NoSuchVersion. assert_eq!( server.get_child_version(parent_version_id).await?, GetVersionResult::NoSuchVersion ); - // Add a first version. - let (rst, _) = server + // After add_version, lookup returns the Version with matching ids and payload. + let history = b"1234".to_vec(); + let AddVersionResult::Ok(version_id) = server .add_version(parent_version_id, history.clone()) - .await?; - - let AddVersionResult::Ok(version_id) = rst else { - panic!("Couldn't add version"); + .await? + .0 + else { + panic!("add_version failed"); }; - // Now we should be able to get the version. match server.get_child_version(parent_version_id).await? { GetVersionResult::Version { version_id: v_id, @@ -1130,4 +1134,32 @@ mod test { ); Ok(()) } + + /// A corrupted version file should return `Err`. + #[tokio::test] + async fn test_get_child_version_corrupted_file() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + // Add a real version so we know the parent UUID. + let parent = Uuid::new_v4(); + let (result, _) = server.add_version(parent, b"good data".to_vec()).await?; + let AddVersionResult::Ok(child) = result else { + panic!("add_version failed"); + }; + + // Overwrite the version file with garbage to simulate corruption. + let filename = format!("v-{}-{}", parent.simple(), child.simple()); + std::fs::write(tmp.path().join(&filename), b"this is not valid ciphertext")?; + + // get_child_version should return Err (decryption failure), not NoSuchVersion. + let result = server.get_child_version(parent).await; + assert!( + result.is_err(), + "expected Err on decryption failure, got: {:?}", + result + ); + + Ok(()) + } } From 022948aa237745cb3d72fca0f68cc5cb86613101 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 17 Apr 2026 21:04:45 -0700 Subject: [PATCH 15/16] misc cleanup --- src/server/gitsync/mod.rs | 114 ++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 65 deletions(-) diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs index 5362a88bb..a0a606032 100644 --- a/src/server/gitsync/mod.rs +++ b/src/server/gitsync/mod.rs @@ -57,15 +57,11 @@ use std::path::{Path, PathBuf}; use std::process::Command; use uuid::Uuid; -/// A version record stored in a version file on disk. -#[serde_as] -#[derive(Serialize, Deserialize, Debug)] +/// A version record; used as an in-memory carrier between read/write helpers. +#[derive(Debug)] struct Version { - #[serde(with = "uuid::serde::simple")] version_id: VersionId, - #[serde(with = "uuid::serde::simple")] parent_version_id: VersionId, - #[serde_as(as = "Base64")] history_segment: HistorySegment, } @@ -106,6 +102,22 @@ pub(crate) struct GitSyncServer { cryptor: Cryptor, } +/// Load and deserialise a [`Meta`] from the given path. +fn load_meta(path: &Path) -> Result { + let file = File::open(path)?; + Ok(serde_json::from_reader(BufReader::new(file))?) +} + +/// Parse a version filename of the form `v-{parent_simple}-{child_simple}` into +/// `(parent_id, child_id)`. Returns `None` if the filename does not match. +fn parse_version_filename(name: &str) -> Option<(Uuid, Uuid)> { + let stem = name.strip_prefix("v-")?; + let (parent_str, child_str) = stem.split_once('-')?; + let parent_id = Uuid::parse_str(parent_str).ok()?; + let child_id = Uuid::parse_str(child_str).ok()?; + Some((parent_id, child_id)) +} + /// Run a git command in a given directory, returning an error if it exits non-zero. fn git_cmd(dir: &Path, args: &[&str]) -> Result<()> { let status = Command::new("git").args(args).current_dir(dir).status()?; @@ -214,12 +226,8 @@ impl GitSyncServer { // Check for meta file, create and commit if missing. let meta_path = local_path.join("meta"); - let meta = match File::open(&meta_path) { - Ok(mut file) => { - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - serde_json::from_str(&contents)? - } + let meta = match load_meta(&meta_path) { + Ok(m) => m, Err(_) => { let m = Meta { latest_version: Uuid::nil(), @@ -256,10 +264,7 @@ impl GitSyncServer { /// Read the meta file from disk and update self.meta. fn read_meta(&mut self) -> Result<()> { - let meta_path = self.local_path.join("meta"); - let file = File::open(&meta_path)?; - let reader = BufReader::new(file); - self.meta = serde_json::from_reader(reader)?; + self.meta = load_meta(&self.local_path.join("meta"))?; Ok(()) } @@ -347,15 +352,11 @@ impl GitSyncServer { &self, parent_version_id: &VersionId, ) -> Result> { - // glob to find file. - // v-PARENT-CHILD - let pattern = format!( - "{}/v-{}-*", - self.local_path - .to_str() - .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?, - parent_version_id.simple() - ); + let path_str = self + .local_path + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + let pattern = format!("{}/v-{}-*", path_str, parent_version_id.simple()); for entry in glob(&pattern).map_err(|e| Error::Server(format!("{:?}", e)))? { let path = match entry { @@ -365,35 +366,27 @@ impl GitSyncServer { continue; } }; - let filename = match path.to_str() { + let filename = match path.file_name().and_then(|n| n.to_str()) { Some(f) => f, None => { log::warn!("non-UTF-8 path, skipping"); continue; } }; - let (_, version_id_str) = match filename.rsplit_once('-') { - Some(parts) => parts, + let version_id = match parse_version_filename(filename) { + Some((_, child)) => child, None => { log::warn!("unexpected filename format: {filename}"); continue; } }; - let version_id = match Uuid::parse_str(version_id_str) { - Ok(id) => id, - Err(e) => { - log::warn!("bad version id in {filename}: {e}"); - continue; - } - }; - // Real errors past this point + // Real errors past this point. let mut buf = Vec::new(); File::open(&path)?.read_to_end(&mut buf)?; - let sealed = Sealed { + let unsealed = self.cryptor.unseal(Sealed { version_id, payload: buf, - }; - let unsealed = self.cryptor.unseal(sealed)?; + })?; return Ok(Some(Version { version_id: unsealed.version_id, parent_version_id: *parent_version_id, @@ -440,12 +433,11 @@ impl GitSyncServer { }; // Parse all v-* filenames into a child -> (parent, path) map. - let pattern = format!( - "{}/v-*", - self.local_path - .to_str() - .ok_or_else(|| Error::Server("repo path is not valid UTF-8".into()))? - ); + let path_str = self + .local_path + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + let pattern = format!("{}/v-*", path_str); let mut versions: HashMap = HashMap::new(); for entry in glob(&pattern).map_err(|e| Error::Server(format!("{e:?}")))? { let path = match entry { @@ -458,16 +450,7 @@ impl GitSyncServer { let Some(filename) = path.file_name().and_then(|f| f.to_str()) else { continue; }; - // Filename format: v-{parent_simple}-{child_simple} - let Some(stem) = filename.strip_prefix("v-") else { - continue; - }; - let Some((parent_str, child_str)) = stem.split_once('-') else { - continue; - }; - let (Ok(parent_id), Ok(child_id)) = - (Uuid::parse_str(parent_str), Uuid::parse_str(child_str)) - else { + let Some((parent_id, child_id)) = parse_version_filename(filename) else { continue; }; versions.insert(child_id, (parent_id, path)); @@ -573,9 +556,9 @@ impl Server for GitSyncServer { self.stage_and_commit(&[&version_path, &meta_path], "add version")?; if !self.push()? { - // Push was rejected, reset and re-read state. + // Push was rejected. Undo the commit; pull will reset --hard and git clean + // away the stray version file. git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; - std::fs::remove_file(&version_path)?; self.pull()?; self.read_meta()?; return Ok(( @@ -591,14 +574,15 @@ impl Server for GitSyncServer { &mut self, parent_version_id: VersionId, ) -> Result { - let version = match self.get_version_by_parent_version_id(&parent_version_id)? { - Some(v) => Some(v), - None => { - self.pull()?; - self.get_version_by_parent_version_id(&parent_version_id)? - } - }; - match version { + if let Some(v) = self.get_version_by_parent_version_id(&parent_version_id)? { + return Ok(GetVersionResult::Version { + version_id: v.version_id, + parent_version_id: v.parent_version_id, + history_segment: v.history_segment, + }); + } + self.pull()?; + match self.get_version_by_parent_version_id(&parent_version_id)? { Some(v) => Ok(GetVersionResult::Version { version_id: v.version_id, parent_version_id: v.parent_version_id, From 8cb46b282614edc99bbc004a84781872d9b90287 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Fri, 17 Apr 2026 22:39:00 -0700 Subject: [PATCH 16/16] lint --- src/server/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/config.rs b/src/server/config.rs index 844c8bb5d..b2e8b360a 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -1,4 +1,5 @@ use super::types::Server; +use crate::errors::Result; #[cfg(feature = "server-aws")] pub use crate::server::cloud::aws::AwsCredentials; #[cfg(feature = "server-aws")] @@ -7,13 +8,12 @@ use crate::server::cloud::aws::AwsService; use crate::server::cloud::gcp::GcpService; #[cfg(feature = "cloud")] use crate::server::cloud::CloudServer; +#[cfg(feature = "git-sync")] +use crate::server::gitsync::GitSyncServer; #[cfg(feature = "server-local")] use crate::server::local::LocalServer; #[cfg(feature = "server-sync")] use crate::server::sync::SyncServer; -#[cfg(feature = "git-sync")] -use crate::server::gitsync::GitSyncServer; -use crate::errors::Result; #[cfg(feature = "server-local")] use std::path::PathBuf; #[cfg(all(feature = "git-sync", not(feature = "server-local")))]