From 357de970ee9edf4af7397a1e201959374332844a Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 3 May 2025 16:24:54 +0200 Subject: [PATCH] Save repos --- Cargo.lock | 57 +++++++++++++++ Cargo.toml | 1 + gdn-cli/src/commands/note/list.rs | 13 ++-- gdn/Cargo.toml | 1 + gdn/src/data.rs | 2 +- gdn/src/data/v1.rs | 5 ++ gdn/src/ids.rs | 34 +++++---- gdn/src/repo.rs | 68 ++++++++++++++---- gdn/src/repo/v0.rs | 7 +- gdn/src/repo/v1.rs | 111 ++++++++++++++++++++++++++++-- 10 files changed, 252 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3776a55..892d16f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,6 +1316,7 @@ dependencies = [ "anyhow", "directories", "git2", + "jiff", "rand 0.9.1", "serde", "serde_json", @@ -1960,6 +1961,47 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e77966151130221b079bcec80f1f34a9e414fa489d99152a201c07fd2182bc" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-static" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97265751f8a9a4228476f2fc17874a9e7e70e96b893368e42619880fe143b48a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2913,6 +2955,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index d4bbe01..8e4e6e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ clap = { version = "4.5.37", features = ["derive", "deprecated"] } directories = "6.0.0" gdn = { path = "gdn" } git2 = { version = "0.20.1", features = ["vendored-libgit2", "vendored-openssl"] } +jiff = "0.2.11" rand = "0.9.1" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" diff --git a/gdn-cli/src/commands/note/list.rs b/gdn-cli/src/commands/note/list.rs index cf84fd5..0a0d4f7 100644 --- a/gdn-cli/src/commands/note/list.rs +++ b/gdn-cli/src/commands/note/list.rs @@ -14,18 +14,17 @@ impl Command { println!("No repo selected"); return Ok(()); }; - let repo = gdn::data::load_repo(&data, selected)?; - let mut notes = repo.notes.into_iter().collect::>(); - notes.sort_unstable_by_key(|(id, _)| *id); + let mut repo = gdn::data::load_repo(&data, selected)?; + repo.notes.sort_unstable_by_key(|it| it.id); - if notes.is_empty() { + if repo.notes.is_empty() { println!("No notes"); return Ok(()); } - for (id, note) in notes { + for note in repo.notes { if note.children.is_empty() { - println!("{id}: {}", note.text); + println!("{}: {}", note.id, note.text); } else { let children = note .children @@ -33,7 +32,7 @@ impl Command { .map(|it| it.to_string()) .collect::>() .join(", "); - println!("{id}: {} [{children}]", note.text); + println!("{}: {} [{children}]", note.id, note.text); } } diff --git a/gdn/Cargo.toml b/gdn/Cargo.toml index c5f7115..454be4c 100644 --- a/gdn/Cargo.toml +++ b/gdn/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } anyhow = { workspace = true } directories = { workspace = true } git2 = { workspace = true } +jiff = { workspace = true } rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/gdn/src/data.rs b/gdn/src/data.rs index 39ede80..1e8feed 100644 --- a/gdn/src/data.rs +++ b/gdn/src/data.rs @@ -13,7 +13,7 @@ pub use crate::repo::VERSION as REPO_VERSION; pub use self::{ datadir::{LockedDataDir, UnlockedDataDir}, v1::{ - State, VERSION, add_repo, load_repo, load_repo_version, load_state, remove_repo, + State, VERSION, add_repo, load_repo, load_repo_version, load_state, remove_repo, save_repo, select_repo, tidy, }, }; diff --git a/gdn/src/data/v1.rs b/gdn/src/data/v1.rs index 8f8825d..190df5f 100644 --- a/gdn/src/data/v1.rs +++ b/gdn/src/data/v1.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, fs, path::PathBuf}; use anyhow::anyhow; +use git2::Oid; use serde::{Deserialize, Serialize}; use crate::{ @@ -82,6 +83,10 @@ pub fn load_repo(dir: &UnlockedDataDir, id: RepoId) -> anyhow::Result { repo::load(&repo_dir(dir, id)) } +pub fn save_repo(dir: &LockedDataDir, id: RepoId, repo: Repo) -> anyhow::Result { + repo::save(&repo_dir(dir, id), repo) +} + pub fn add_repo(dir: &LockedDataDir, name: String) -> anyhow::Result { let id = RepoId::new(); diff --git a/gdn/src/ids.rs b/gdn/src/ids.rs index c967dbc..e7dcfa2 100644 --- a/gdn/src/ids.rs +++ b/gdn/src/ids.rs @@ -1,5 +1,6 @@ -use std::{fmt, ops::Shl, str::FromStr, time::SystemTime}; +use std::{fmt, str::FromStr}; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A timestamp- and randomness-based id. @@ -13,19 +14,18 @@ struct TimestampId(u64); impl TimestampId { fn new() -> Self { - let secs = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("duration is positive") - .as_secs(); + let secs: u64 = Timestamp::now() + .as_second() + .try_into() + .expect("timestamp out of range"); + assert!(secs < 0x000000FF_FFFFFFFF_u64, "timestamp out of range"); + let random = rand::random::() & 0x00000000_00FFFFFF_u64; + Self(secs << (3 * 8) | random) + } - let random = rand::random::(); - - // Zeroing the last three bytes just in case. They should already be - // zero under normal circumstances. - let first_part = secs.shl(3 * 8) & 0xFFFFFFFF_FF000000_u64; - let second_part = random & 0x00000000_00FFFFFF_u64; - - Self(first_part | second_part) + fn timestamp(self) -> Timestamp { + let secs = self.0 >> (3 * 8); + Timestamp::from_second(secs as i64).expect("timestamp out of range") } } @@ -62,6 +62,14 @@ impl NoteId { pub fn new() -> Self { Self(TimestampId::new()) } + + pub fn timestamp(self) -> Timestamp { + self.0.timestamp() + } + + pub fn time_utc(self) -> Zoned { + self.timestamp().to_zoned(TimeZone::UTC) + } } impl fmt::Debug for NoteId { diff --git a/gdn/src/repo.rs b/gdn/src/repo.rs index a0c8e42..3595f33 100644 --- a/gdn/src/repo.rs +++ b/gdn/src/repo.rs @@ -4,7 +4,8 @@ mod v1; use std::path::Path; use anyhow::{anyhow, bail}; -use git2::{ErrorCode, Repository}; +use git2::{Commit, ErrorCode, Oid, Reference, Repository}; +use jiff::Zoned; pub use self::v1::{Repo, VERSION}; @@ -15,18 +16,19 @@ pub fn init(path: &Path) -> anyhow::Result<()> { Ok(()) } -fn read_version(repo: &Repository) -> anyhow::Result { - let head = match repo.head() { - Ok(head) => head, - Err(error) if error.code() == ErrorCode::UnbornBranch => return Ok(0), +fn read_head(repository: &Repository) -> anyhow::Result>> { + match repository.head() { + Ok(head) => Ok(Some(head)), + Err(error) if error.code() == ErrorCode::UnbornBranch => Ok(None), Err(error) => Err(error)?, - }; + } +} - let object = head - .peel_to_commit()? +fn read_version(repository: &Repository, commit: &Commit<'_>) -> anyhow::Result { + let object = commit .tree()? .get_path(VERSION_FILE.as_ref())? - .to_object(repo)?; + .to_object(repository)?; let blob = object .as_blob() @@ -39,21 +41,59 @@ fn read_version(repo: &Repository) -> anyhow::Result { } pub fn load_version(path: &Path) -> anyhow::Result { - let repo = Repository::open_bare(path)?; - let version = read_version(&repo)?; + let repository = Repository::open_bare(path)?; + let Some(head) = read_head(&repository)? else { + return Ok(v0::VERSION); + }; + let commit = head.peel_to_commit()?; + let version = read_version(&repository, &commit)?; Ok(version) } pub fn load(path: &Path) -> anyhow::Result { let repository = Repository::open_bare(path)?; - let version = read_version(&repository)?; + let Some(head) = read_head(&repository)? else { + return Ok(v0::Repo::load().migrate()); + }; + let commit = head.peel_to_commit()?; + let version = read_version(&repository, &commit)?; + let tree = commit.tree()?; #[expect(unused_qualifications)] let repo = match version { - v0::VERSION => v0::Repo::load().migrate(), - v1::VERSION => v1::Repo::load(&repository)?.migrate(), + v1::VERSION => v1::Repo::load_from_tree(&repository, &tree)?.migrate(), n => bail!("invalid repo version {n}"), }; Ok(repo) } + +pub fn save(path: &Path, repo: Repo) -> anyhow::Result { + let repository = Repository::open_bare(path)?; + + let tree = repo.save_to_tree(&repository)?; + + let signature = repository.signature()?; + let message = Zoned::now().to_string(); + + // TODO Check that the repo is actually based on this commit. + let parent = match read_head(&repository)? { + None => None, + Some(parent) => Some(parent.peel_to_commit()?), + }; + let parents = match &parent { + None => vec![], + Some(parent) => vec![parent], + }; + + let oid = repository.commit( + Some("HEAD"), + &signature, + &signature, + &message, + &tree, + &parents, + )?; + + Ok(oid) +} diff --git a/gdn/src/repo/v0.rs b/gdn/src/repo/v0.rs index 14b2e9c..2a3e825 100644 --- a/gdn/src/repo/v0.rs +++ b/gdn/src/repo/v0.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use super::v1; pub const VERSION: u32 = 0; @@ -12,9 +10,6 @@ impl Repo { } pub fn migrate(self) -> super::Repo { - v1::Repo { - notes: HashMap::new(), - } - .migrate() + v1::Repo { notes: vec![] }.migrate() } } diff --git a/gdn/src/repo/v1.rs b/gdn/src/repo/v1.rs index 792c85d..a5be619 100644 --- a/gdn/src/repo/v1.rs +++ b/gdn/src/repo/v1.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - -use git2::Repository; +use anyhow::anyhow; +use git2::{FileMode, Repository, Tree, TreeBuilder, TreeEntry, TreeWalkMode, TreeWalkResult}; use serde::{Deserialize, Serialize}; use crate::ids::NoteId; @@ -9,18 +8,118 @@ pub const VERSION: u32 = 1; #[derive(Serialize, Deserialize)] pub struct Note { + pub id: NoteId, pub text: String, pub children: Vec, } #[derive(Default)] pub struct Repo { - pub notes: HashMap, + pub notes: Vec, +} + +fn add_note_to_tree( + repository: &Repository, + target: &mut TreeBuilder<'_>, + note: &Note, +) -> anyhow::Result<()> { + let filename = format!("{}.json", note.id); + let oid = repository.blob(&serde_json::to_vec(note)?)?; + target.insert(filename, oid, FileMode::Blob.into())?; + Ok(()) +} + +fn add_tree_to_tree( + target: &mut TreeBuilder<'_>, + tree: &TreeBuilder<'_>, + filename: String, +) -> anyhow::Result<()> { + if tree.is_empty() { + return Ok(()); + } + let oid = tree.write()?; + target.insert(filename, oid, FileMode::Tree.into())?; + Ok(()) +} + +fn load_note( + repository: &Repository, + entry: &TreeEntry<'_>, + notes: &mut Vec, +) -> anyhow::Result<()> { + let object = entry.to_object(repository)?; + let content = object + .as_blob() + .ok_or(anyhow!("json file is not a blob!?"))? + .content(); + let note = serde_json::from_slice(content)?; + notes.push(note); + Ok(()) } impl Repo { - pub fn load(repository: &Repository) -> anyhow::Result { - todo!() + pub fn load_from_tree(repository: &Repository, tree: &Tree<'_>) -> anyhow::Result { + let mut notes = vec![]; + let mut error: Option = None; + + tree.walk(TreeWalkMode::PreOrder, |name, entry| { + if name.ends_with(".json") { + if let Err(err) = load_note(repository, entry, &mut notes) { + error = Some(err); + return TreeWalkResult::Abort; + } + } + TreeWalkResult::Ok + })?; + + if let Some(err) = error { + return Err(err); + } + + Ok(Self { notes }) + } + + pub fn save_to_tree(mut self, repository: &Repository) -> anyhow::Result> { + self.notes.sort_unstable_by_key(|it| it.id); + + let mut root_tree = repository.treebuilder(None)?; + let mut year = 0; + let mut year_tree = repository.treebuilder(None)?; + let mut month = 0; + let mut month_tree = repository.treebuilder(None)?; + let mut day = 0; + let mut day_tree = repository.treebuilder(None)?; + + for note in self.notes { + let time = note.id.time_utc(); + + if day != time.day() || month != time.month() || year != time.year() { + add_tree_to_tree(&mut month_tree, &day_tree, format!("{day:02}"))?; + day_tree.clear()?; + day = time.day(); + } + + if month != time.month() || year != time.year() { + add_tree_to_tree(&mut year_tree, &month_tree, format!("{month:02}"))?; + month_tree.clear()?; + month = time.month(); + } + + if year != time.year() { + add_tree_to_tree(&mut root_tree, &year_tree, format!("{year:04}"))?; + year_tree.clear()?; + year = time.year(); + } + + add_note_to_tree(repository, &mut day_tree, ¬e)?; + } + + add_tree_to_tree(&mut month_tree, &day_tree, format!("{day:02}"))?; + add_tree_to_tree(&mut year_tree, &month_tree, format!("{month:02}"))?; + add_tree_to_tree(&mut root_tree, &year_tree, format!("{year:04}"))?; + + let tree = repository.find_tree(root_tree.write()?)?; + Ok(tree) } pub fn migrate(self) -> Self {