Save repos

This commit is contained in:
Joscha 2025-05-03 16:24:54 +02:00
parent d08922e753
commit 357de970ee
10 changed files with 252 additions and 47 deletions

57
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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::<Vec<_>>();
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::<Vec<_>>()
.join(", ");
println!("{id}: {} [{children}]", note.text);
println!("{}: {} [{children}]", note.id, note.text);
}
}

View file

@ -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 }

View file

@ -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,
},
};

View file

@ -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> {
repo::load(&repo_dir(dir, id))
}
pub fn save_repo(dir: &LockedDataDir, id: RepoId, repo: Repo) -> anyhow::Result<Oid> {
repo::save(&repo_dir(dir, id), repo)
}
pub fn add_repo(dir: &LockedDataDir, name: String) -> anyhow::Result<RepoId> {
let id = RepoId::new();

View file

@ -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::<u64>() & 0x00000000_00FFFFFF_u64;
Self(secs << (3 * 8) | random)
}
let random = rand::random::<u64>();
// 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 {

View file

@ -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<u32> {
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<Option<Reference<'_>>> {
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<u32> {
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<u32> {
}
pub fn load_version(path: &Path) -> anyhow::Result<u32> {
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<Repo> {
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<Oid> {
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)
}

View file

@ -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()
}
}

View file

@ -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<NoteId>,
}
#[derive(Default)]
pub struct Repo {
pub notes: HashMap<NoteId, Note>,
pub notes: Vec<Note>,
}
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<Note>,
) -> 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<Self> {
todo!()
pub fn load_from_tree(repository: &Repository, tree: &Tree<'_>) -> anyhow::Result<Self> {
let mut notes = vec![];
let mut error: Option<anyhow::Error> = 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<Tree<'_>> {
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, &note)?;
}
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 {