From ccabfa4ec8367a9e99d060cdd4979ca85a6823c6 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 10:49:10 -0600 Subject: [PATCH 1/4] remove an old debug log message from Issue::new_comment() --- src/issue.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index c762c82..e099616 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -75,7 +75,6 @@ impl fmt::Display for State { State::InProgress => "inprogress", State::Done => "done", State::WontDo => "wontdo", - }; write!(f, "{fmt_str}") } @@ -145,7 +144,6 @@ impl Issue { let mut dir = std::path::PathBuf::from(&self.dir); dir.push("comments"); if !dir.exists() { - println!("creating {}", dir.to_string_lossy()); std::fs::create_dir(&dir)?; } From 9c54a92152f58d7868023eb2ffcadf9b9e0f4dcb Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 10:18:45 -0600 Subject: [PATCH 2/4] add `ent sync` In a worktree with the `entomologist-data` branch checked out in it: 1. `git fetch REMOTE` 2. `git merge REMOTE/BRANCH` 3. `git push REMOTE BRANCH` Pretty straight-forward. If anything goes wrong we error out and ask the human to help. --- src/bin/ent/main.rs | 27 ++++++++++++++++++++++ src/git.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1c9ddaa..1363641 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -45,6 +45,15 @@ enum Commands { issue_id: String, description: Option, }, + + /// Sync entomologist data with remote. This fetches from the remote, + /// merges the remote entomologist data branch with the local one, + /// and pushes the result back to the remote. + Sync { + /// Name of the git remote to sync with. + #[arg(default_value_t = String::from("origin"))] + remote: String, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -154,6 +163,24 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + + Commands::Sync { remote } => { + if args.issues_dir.is_some() { + return Err(anyhow::anyhow!( + "`sync` operates on a branch, don't specify `issues_dir`" + )); + } + // FIXME: Kinda bogus to re-do this thing we just did in + // `main()`. Maybe `main()` shouldn't create the worktree, + // maybe we should do it here in `handle_command()`? + // That way also each command could decide if it wants a + // read-only worktree or a read/write one. + let branch = match &args.issues_branch { + Some(branch) => branch, + None => "entomologist-data", + }; + entomologist::git::sync(issues_dir, remote, branch)?; + } } Ok(()) diff --git a/src/git.rs b/src/git.rs index caa5e4b..30f703e 100644 --- a/src/git.rs +++ b/src/git.rs @@ -124,6 +124,62 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { Ok(()) } +pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["fetch", remote]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(()) +} + +pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), GitError> { + // We do all the work in a directory that's (FIXME) hopefully a + // worktree. If anything goes wrong we just fail out and ask the + // human to fix it by hand :-/ + // 1. `git fetch` + // 2. `git merge REMOTE/BRANCH` + // 3. `git push REMOTE BRANCH` + + git_fetch(dir, remote)?; + + // Merge remote branch into local. + let result = std::process::Command::new("git") + .args(["merge", &format!("{}/{}", remote, branch)]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + // Push merged branch to remote. + let result = std::process::Command::new("git") + .args(["push", remote, branch]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); From 2106c69271716c0c7a76e9a200b96b83764f31c8 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 11:10:03 -0600 Subject: [PATCH 3/4] `ent sync`: report success if it all worked --- src/bin/ent/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1363641..10d0637 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -180,6 +180,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( None => "entomologist-data", }; entomologist::git::sync(issues_dir, remote, branch)?; + println!("synced {:?} with {:?}", branch, remote); } } From 1fa3aae2c0b4f23d11c5b3a0ed312aada590f132 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 23:45:03 -0600 Subject: [PATCH 4/4] give Comment a timestamp, display in chronological order This commit makes a couple of changes: - `ent show ISSUE` now displays the Issue's Comments in chronological order - the Comment struct now includes a timestamp, which is the Author Time of the oldest commit that touches the comment's directory - the Issue struct now stores its Comments in a sorted Vec, not in a HashMap - The Comment's uuid moved into the Comment struct itself, instead of being the key in the Issue's HashMap of Comments --- Cargo.toml | 1 + src/bin/ent/main.rs | 5 +++-- src/comment.rs | 13 +++++++++++-- src/git.rs | 28 ++++++++++++++++++++++++++++ src/issue.rs | 21 ++++++++++++--------- src/issues.rs | 20 ++++++++++---------- 6 files changed, 65 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fccb9c9..af271d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ log = ["dep:log", "dep:simple_logger"] [dependencies] anyhow = "1.0.95" +chrono = "0.4.41" clap = { version = "4.5.26", features = ["derive"] } log = { version = "0.4.27", optional = true } rand = "0.9.1" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 10d0637..cae0806 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -105,9 +105,10 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } println!(""); println!("{}", issue.description); - for (uuid, comment) in issue.comments.iter() { + for comment in &issue.comments { println!(""); - println!("comment: {}", uuid); + println!("comment: {}", comment.uuid); + println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } } diff --git a/src/comment.rs b/src/comment.rs index ab2ced9..8cd00a0 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -2,6 +2,8 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { + pub uuid: String, + pub timestamp: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -39,12 +41,16 @@ impl Comment { } } } - if description == None { return Err(CommentError::CommentParseError); } + let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; + let dir = std::path::PathBuf::from(comment_dir); + Ok(Self { + uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + timestamp, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), }) @@ -95,8 +101,11 @@ mod tests { std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { + uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), }; assert_eq!(comment, expected); diff --git a/src/git.rs b/src/git.rs index 30f703e..d26293a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,6 +4,8 @@ use std::io::Write; pub enum GitError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), #[error("Oops, something went wrong")] Oops, } @@ -180,6 +182,32 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git Ok(()) } +pub fn git_log_oldest_timestamp( + path: &std::path::Path, +) -> Result, GitError> { + let mut git_dir = std::path::PathBuf::from(path); + git_dir.pop(); + let result = std::process::Command::new("git") + .args([ + "log", + "--pretty=format:%at", + &path.file_name().unwrap().to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); + let timestamp_i64 = timestamp_str.parse::()?; + let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + Ok(timestamp) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); diff --git a/src/issue.rs b/src/issue.rs index e099616..1e84294 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,7 +23,7 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, - pub comments: std::collections::HashMap, + pub comments: Vec, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -85,7 +85,7 @@ impl Issue { let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; - let mut comments = std::collections::HashMap::::new(); + let mut comments = Vec::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -127,16 +127,16 @@ impl Issue { } fn read_comments( - comments: &mut std::collections::HashMap, + comments: &mut Vec, dir: &std::path::Path, ) -> Result<(), IssueError> { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { - let uuid = direntry.file_name(); let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; - comments.insert(String::from(uuid.to_string_lossy()), comment); + comments.push(comment); } } + comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); Ok(()) } @@ -148,10 +148,13 @@ impl Issue { } let rnd: u128 = rand::random(); - dir.push(&format!("{:032x}", rnd)); + let uuid = format!("{:032x}", rnd); + dir.push(&uuid); std::fs::create_dir(&dir)?; Ok(crate::comment::Comment { + uuid, + timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, }) @@ -166,7 +169,7 @@ impl Issue { description: String::from(""), // FIXME: kind of bogus to use the empty string as None state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: issue_dir, }) } @@ -242,7 +245,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); @@ -256,7 +259,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); diff --git a/src/issues.rs b/src/issues.rs index 28df2e9..06af867 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -99,7 +99,7 @@ mod tests { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -113,7 +113,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, } ); @@ -136,7 +136,7 @@ mod tests { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -148,12 +148,12 @@ mod tests { let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); comment_dir.push("comments"); comment_dir.push(&comment_uuid); - let mut expected_comments = - std::collections::HashMap::::new(); - expected_comments.insert( - String::from(&comment_uuid), + let mut expected_comments = Vec::::new(); + expected_comments.push( crate::comment::Comment { + uuid: comment_uuid, description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), dir: std::path::PathBuf::from(comment_dir), } ); @@ -186,7 +186,7 @@ mod tests { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -200,7 +200,7 @@ mod tests { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -217,7 +217,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, );