From b9979f5e9ecee98ae16a3d571c535077678262f0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 12:14:26 -0600 Subject: [PATCH 1/6] start adding Issue struct This abstracts a single issue. --- Cargo.toml | 1 + src/issue.rs | 109 ++++++++++++++++++ src/lib.rs | 2 +- .../description | 4 + .../title | 1 + .../state | 1 + .../title | 1 + 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/issue.rs create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title diff --git a/Cargo.toml b/Cargo.toml index ba3bd7b..905a9b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] +thiserror = "2.0.11" diff --git a/src/issue.rs b/src/issue.rs new file mode 100644 index 0000000..6dde005 --- /dev/null +++ b/src/issue.rs @@ -0,0 +1,109 @@ +use std::str::FromStr; + +#[derive(Debug, PartialEq, serde::Deserialize)] +/// These are the states an issue can be in. +pub enum State { + New, + Backlog, + InProgress, + Done, + WontDo, +} + +#[derive(Debug, PartialEq)] +pub struct Issue { + pub title: String, + pub description: Option, + pub state: State, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadIssueError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Failed to parse issue")] + IssueParseError, +} + +impl FromStr for State { + type Err = ReadIssueError; + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + if s == "new" { + Ok(State::New) + } else if s == "backlog" { + Ok(State::Backlog) + } else if s == "inprogress" { + Ok(State::InProgress) + } else if s == "done" { + Ok(State::Done) + } else if s == "wontdo" { + Ok(State::WontDo) + } else { + Err(ReadIssueError::IssueParseError) + } + } +} + +impl Issue { + pub fn new_from_dir(dir: &std::path::Path) -> Result { + let mut title: Option = None; + let mut description: Option = None; + let mut state = State::New; // default state, if not specified in the issue + + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + let file_name = direntry.file_name(); + if file_name == "title" { + title = Some(std::fs::read_to_string(direntry.path())?.trim().into()); + } else if file_name == "description" { + description = Some(std::fs::read_to_string(direntry.path())?); + } else if file_name == "state" { + let state_string = std::fs::read_to_string(direntry.path())?; + state = State::from_str(state_string.trim())?; + } else { + println!("ignoring unknown file in issue directory: {:?}", file_name); + } + } + } + + if title == None { + return Err(ReadIssueError::IssueParseError); + } + + Ok(Self { + title: title.unwrap(), + description: description, + state: state, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_issue_0() { + let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); + let issue = Issue::new_from_dir(issue_dir).unwrap(); + let expected = Issue { + title: String::from("this is the title of my issue"), + description: Some(String::from("This 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, + }; + assert_eq!(issue, expected); + } + + #[test] + fn read_issue_1() { + let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); + let issue = Issue::new_from_dir(issue_dir).unwrap(); + let expected = Issue { + title: String::from("minimal"), + description: None, + state: State::InProgress, + }; + assert_eq!(issue, expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8b13789..d93d369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1 @@ - +pub mod issue; diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description new file mode 100644 index 0000000..3db0fcf --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description @@ -0,0 +1,4 @@ +This is the description of my issue. +It is multiple lines. +* Arbitrary contents +* But let's use markdown by convention diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title new file mode 100644 index 0000000..c9c2379 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title @@ -0,0 +1 @@ +this is the title of my issue diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state new file mode 100644 index 0000000..0737713 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state @@ -0,0 +1 @@ +inprogress diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title new file mode 100644 index 0000000..dd1a932 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title @@ -0,0 +1 @@ +minimal From 16c6288cee4fcca178ef129952865e171e25a88a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 11:59:44 -0600 Subject: [PATCH 2/6] start adding Issues struct This holds everything there is to know about everything, for now that's all issues but in the future there might be more? --- Cargo.toml | 2 + src/issues.rs | 125 ++++++++++++++++++ src/lib.rs | 1 + .../state | 1 + .../title | 1 + test/0001/config.toml | 1 + .../description | 4 + .../state | 1 + .../title | 1 + 9 files changed, 137 insertions(+) create mode 100644 src/issues.rs create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title create mode 100644 test/0001/config.toml create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title diff --git a/Cargo.toml b/Cargo.toml index 905a9b5..2230bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] +serde = { version = "1.0.217", features = ["derive"] } thiserror = "2.0.11" +toml = "0.8.19" diff --git a/src/issues.rs b/src/issues.rs new file mode 100644 index 0000000..b5c1c87 --- /dev/null +++ b/src/issues.rs @@ -0,0 +1,125 @@ +// Just a placeholder for now, get rid of this if we don't need it. +#[derive(Debug, PartialEq, serde::Deserialize)] +pub struct Config {} + +#[derive(Debug, PartialEq)] +pub struct Issues { + pub issues: std::collections::HashMap, + pub config: Config, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadIssuesError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Failed to parse issue")] + IssueParseError(#[from] crate::issue::ReadIssueError), + #[error("cannot handle filename")] + FilenameError(std::ffi::OsString), + #[error(transparent)] + TomlDeserializeError(#[from] toml::de::Error), +} + +impl Issues { + pub fn new() -> Self { + Self { + issues: std::collections::HashMap::new(), + config: Config {}, + } + } + + pub fn add_issue(&mut self, uuid: String, issue: crate::issue::Issue) { + self.issues.insert(uuid, issue); + } + + fn parse_config(&mut self, config_path: &std::path::Path) -> Result<(), ReadIssuesError> { + let config_contents = std::fs::read_to_string(config_path)?; + let config: Config = toml::from_str(&config_contents)?; + self.config = config; + Ok(()) + } + + pub fn new_from_dir(dir: &std::path::Path) -> Result { + let mut issues = Self::new(); + + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + if direntry.metadata()?.is_dir() { + let uuid = match direntry.file_name().into_string() { + Ok(uuid) => uuid, + Err(orig_string) => { + return Err(ReadIssuesError::FilenameError(orig_string)) + } + }; + let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; + issues.add_issue(uuid, issue); + } else if direntry.file_name() == "config.toml" { + issues.parse_config(direntry.path().as_path())?; + } else { + println!( + "ignoring unknown file in issues directory: {:?}", + direntry.file_name() + ); + } + } + } + return Ok(issues); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_issues_0000() { + let issues_dir = std::path::Path::new("test/0000/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + expected.add_issue( + String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), + crate::issue::Issue { + title: String::from("minimal"), + description: None, + state: crate::issue::State::InProgress, + }, + ); + expected.add_issue( + String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), + crate::issue::Issue { + title: String::from("this is the title of my issue"), + description: Some(String::from("This 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, + } + ); + assert_eq!(issues, expected); + } + + #[test] + fn read_issues_0001() { + let issues_dir = std::path::Path::new("test/0001/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + expected.add_issue( + String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::Issue { + title: String::from("oh yeah we got titles"), + description: None, + state: crate::issue::State::Done, + }, + ); + expected.add_issue( + String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + crate::issue::Issue { + title: String::from("issues out the wazoo"), + description: Some(String::from( + "Lots of words\nthat don't say much\nbecause this is just\na test\n", + )), + state: crate::issue::State::WontDo, + }, + ); + assert_eq!(issues, expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index d93d369..713acd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,2 @@ pub mod issue; +pub mod issues; diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state @@ -0,0 +1 @@ +done diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title new file mode 100644 index 0000000..18a1926 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title @@ -0,0 +1 @@ +oh yeah we got titles diff --git a/test/0001/config.toml b/test/0001/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0001/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description new file mode 100644 index 0000000..010156b --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -0,0 +1,4 @@ +Lots of words +that don't say much +because this is just +a test diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title new file mode 100644 index 0000000..ab5b4a9 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title @@ -0,0 +1 @@ +issues out the wazoo From d94c991eaac39dfd25fa3dd1d6317f352705693d Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sat, 5 Jul 2025 13:55:49 -0600 Subject: [PATCH 3/6] add dependency tracking to issue type --- src/issue.rs | 18 +++++++- src/issues.rs | 45 +++++++++++++++++++ .../state | 1 + .../title | 1 + .../dependencies | 2 + .../description | 3 ++ .../state | 1 + .../title | 1 + test/0002/config.toml | 1 + .../description | 4 ++ .../state | 1 + .../title | 1 + 12 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state create mode 100644 test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title create mode 100644 test/0002/config.toml create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title diff --git a/src/issue.rs b/src/issue.rs index 6dde005..7171380 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,16 +5,20 @@ use std::str::FromStr; pub enum State { New, Backlog, + Blocked, InProgress, Done, WontDo, } +pub type IssueHandle = String; + #[derive(Debug, PartialEq)] pub struct Issue { pub title: String, pub description: Option, pub state: State, + pub dependencies: Option>, } #[derive(Debug, thiserror::Error)] @@ -33,6 +37,8 @@ impl FromStr for State { Ok(State::New) } else if s == "backlog" { Ok(State::Backlog) + } else if s == "blocked" { + Ok(State::Blocked) } else if s == "inprogress" { Ok(State::InProgress) } else if s == "done" { @@ -50,6 +56,7 @@ impl Issue { let mut title: Option = None; let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue + let mut dependencies: Option> = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -60,7 +67,13 @@ impl Issue { description = Some(std::fs::read_to_string(direntry.path())?); } else if file_name == "state" { let state_string = std::fs::read_to_string(direntry.path())?; - state = State::from_str(state_string.trim())?; + state = State::from_str(state_string.trim())?; + } else if file_name == "dependencies" { + let dep_strings = std::fs::read_to_string(direntry.path())?; + let deps: Vec = dep_strings.lines().map(|dep|{IssueHandle::from(dep)}).collect(); + if deps.len() > 0 { + dependencies = Some(deps); + } } else { println!("ignoring unknown file in issue directory: {:?}", file_name); } @@ -75,6 +88,7 @@ impl Issue { title: title.unwrap(), description: description, state: state, + dependencies, }) } } @@ -91,6 +105,7 @@ mod tests { title: String::from("this is the title of my issue"), description: Some(String::from("This 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, }; assert_eq!(issue, expected); } @@ -103,6 +118,7 @@ mod tests { title: String::from("minimal"), description: None, state: State::InProgress, + dependencies: None, }; assert_eq!(issue, expected); } diff --git a/src/issues.rs b/src/issues.rs index b5c1c87..35116e7 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -83,6 +83,7 @@ mod tests { title: String::from("minimal"), description: None, state: crate::issue::State::InProgress, + dependencies: None, }, ); expected.add_issue( @@ -91,6 +92,7 @@ mod tests { title: String::from("this is the title of my issue"), description: Some(String::from("This 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, } ); assert_eq!(issues, expected); @@ -108,6 +110,7 @@ mod tests { title: String::from("oh yeah we got titles"), description: None, state: crate::issue::State::Done, + dependencies: None, }, ); expected.add_issue( @@ -118,8 +121,50 @@ mod tests { "Lots of words\nthat don't say much\nbecause this is just\na test\n", )), state: crate::issue::State::WontDo, + dependencies: None, + }, + ); + assert_eq!(issues, expected); + } + + #[test] + fn read_issues_0002() { + let issues_dir = std::path::Path::new("test/0002/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + expected.add_issue( + String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::Issue { + title: String::from("oh yeah we got titles"), + description: None, + state: crate::issue::State::Done, + dependencies: None, + }, + ); + expected.add_issue( + String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + crate::issue::Issue { + title: String::from("issues out the wazoo"), + description: Some(String::from( + "Lots of words\nthat don't say much\nbecause this is just\na test\n", + )), + state: crate::issue::State::WontDo, + dependencies: None, + }, + ); + expected.add_issue( + String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"), + crate::issue::Issue { + title: String::from("issue with dependencies"), + description: Some(String::from( + "a test has begun\nfor dependencies we seek\nintertwining life", + )), + state: crate::issue::State::WontDo, + dependencies: Some(vec![crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561")]), }, ); assert_eq!(issues, expected); } } + diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state @@ -0,0 +1 @@ +done diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title new file mode 100644 index 0000000..18a1926 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title @@ -0,0 +1 @@ +oh yeah we got titles diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies new file mode 100644 index 0000000..71e4ee3 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies @@ -0,0 +1,2 @@ +3fa5bfd93317ad25772680071d5ac3259cd2384f +dd79c8cfb8beeacd0460429944b4ecbe95a31561 \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description new file mode 100644 index 0000000..049c15f --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description @@ -0,0 +1,3 @@ +a test has begun +for dependencies we seek +intertwining life \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title new file mode 100644 index 0000000..7c150e7 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title @@ -0,0 +1 @@ +issue with dependencies diff --git a/test/0002/config.toml b/test/0002/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0002/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description new file mode 100644 index 0000000..010156b --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -0,0 +1,4 @@ +Lots of words +that don't say much +because this is just +a test diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title new file mode 100644 index 0000000..ab5b4a9 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title @@ -0,0 +1 @@ +issues out the wazoo From e8910b906a7f05eb2a22c6752994161eb181aa63 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 4 Jul 2025 13:07:10 -0600 Subject: [PATCH 4/6] add git support This mostly provides an abstraction for "ephemeral worktrees", which is a branch checked out in a worktree, to be read and maybe modified, and the worktree is deleted/pruned when we're done with it. There are also some helper functions for doing git things, the most important one creates an orphaned branch. The intent is to keep all the issues in a git branch. When we want to do anything with issues (list them, add new issues, modify an issue, etc) we check the issues branch out into an ephemeral worktree, modify the branch, and delete the worktree. --- Cargo.toml | 2 + src/git.rs | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 203 insertions(+) create mode 100644 src/git.rs diff --git a/Cargo.toml b/Cargo.toml index 2230bb4..8c9d262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } +tempfile = "3.20.0" thiserror = "2.0.11" toml = "0.8.19" diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..2b03b5a --- /dev/null +++ b/src/git.rs @@ -0,0 +1,200 @@ +use std::io::Write; + +#[derive(Debug, thiserror::Error)] +pub enum GitError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Oops, something went wrong")] + Oops, +} + +#[derive(Debug)] +/// `Worktree` is a struct that manages a temporary directory containing +/// a checkout of a specific branch. The worktree is removed and pruned +/// when the `Worktree` struct is dropped. +pub struct Worktree { + path: tempfile::TempDir, +} + +impl Drop for Worktree { + fn drop(&mut self) { + let _result = std::process::Command::new("git") + .args(["worktree", "remove", &self.path.path().to_string_lossy()]) + .output(); + } +} + +impl Worktree { + pub fn new(branch: &str) -> Result { + let path = tempfile::tempdir()?; + let result = std::process::Command::new("git") + .args(["worktree", "add", &path.path().to_string_lossy(), branch]) + .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(Self { path }) + } + + pub fn path(&self) -> &std::path::Path { + self.path.as_ref() + } +} + +pub fn checkout_branch_in_worktree( + branch: &str, + worktree_dir: &std::path::Path, +) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) + .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 git_worktree_prune() -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .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 git_remove_branch(branch: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["branch", "-D", branch]) + .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 git_branch_exists(branch: &str) -> Result { + let result = std::process::Command::new("git") + .args(["show-ref", "--quiet", branch]) + .output()?; + return Ok(result.status.success()); +} + +pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { + { + let tmp_worktree = tempfile::tempdir().unwrap(); + create_orphan_branch_at_path(branch, tmp_worktree.path())?; + } + // The temp dir is now removed / cleaned up. + + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .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(()) +} + +fn create_orphan_branch_at_path( + branch: &str, + worktree_path: &std::path::Path, +) -> Result<(), GitError> { + let worktree_dir = worktree_path.to_string_lossy(); + let result = std::process::Command::new("git") + .args(["worktree", "add", "--orphan", "-b", branch, &worktree_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 mut readme_filename = std::path::PathBuf::from(worktree_path); + readme_filename.push("README.md"); + let mut readme = std::fs::File::create(readme_filename)?; + write!( + readme, + "This branch is used by entomologist to track issues." + )?; + + let result = std::process::Command::new("git") + .args(["add", "README.md"]) + .current_dir(worktree_path) + .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 result = std::process::Command::new("git") + .args(["commit", "-m", "create entomologist issue branch"]) + .current_dir(worktree_path) + .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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_worktree() { + let mut p = std::path::PathBuf::new(); + { + let worktree = Worktree::new("origin/main").unwrap(); + + p.push(worktree.path()); + assert!(p.exists()); + + let mut p2 = p.clone(); + p2.push("README.md"); + assert!(p2.exists()); + } + // The temporary worktree directory is removed when the Temp variable is dropped. + assert!(!p.exists()); + } + + #[test] + fn test_create_orphan_branch() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-test-branch-"); + branch.push_str(&format!("{:0x}", rnd)); + create_orphan_branch(&branch).unwrap(); + git_remove_branch(&branch).unwrap(); + } + + #[test] + fn test_branch_exists_0() { + let r = git_branch_exists("main").unwrap(); + assert_eq!(r, true); + } + + #[test] + fn test_branch_exists_1() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-missing-branch-"); + branch.push_str(&format!("{:0x}", rnd)); + let r = git_branch_exists(&branch).unwrap(); + assert_eq!(r, false); + } +} diff --git a/src/lib.rs b/src/lib.rs index 713acd1..cca1c90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ +pub mod git; pub mod issue; pub mod issues; From 26c98591b52be4f32f5b767de81f1c72d5a94375 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 12:14:33 -0600 Subject: [PATCH 5/6] start adding `ent` binary --- Cargo.toml | 2 ++ src/bin/ent/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/bin/ent/main.rs diff --git a/Cargo.toml b/Cargo.toml index 8c9d262..1f7941d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.95" +clap = { version = "4.5.26", features = ["derive"] } rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } tempfile = "3.20.0" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs new file mode 100644 index 0000000..86dfa95 --- /dev/null +++ b/src/bin/ent/main.rs @@ -0,0 +1,77 @@ +use clap::Parser; + +#[derive(Debug, clap::Parser)] +#[command(version, about, long_about = None)] +struct Args { + /// Directory containing issues. + #[arg(short = 'd', long)] + issues_dir: Option, + + /// Branch containing issues. + #[arg(short = 'b', long)] + issues_branch: Option, + + /// Type of behavior/output. + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand, Debug)] +enum Commands { + /// List issues. + List, + + /// Create a new issue. + New { + title: Option, + description: Option, + }, +} + +fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { + match &args.command { + Commands::List => { + let issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + for (uuid, issue) in issues.issues.iter() { + println!("{} {} ({:?})", uuid, issue.title, issue.state); + } + } + Commands::New { title, description } => { + println!( + "should make a new issue, title={:?}, description={:?}", + title, description + ); + } + } + + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let args: Args = Args::parse(); + // println!("{:?}", args); + + if let (Some(_), Some(_)) = (&args.issues_dir, &args.issues_branch) { + return Err(anyhow::anyhow!( + "don't specify both `--issues-dir` and `--issues-branch`" + )); + } + + if let Some(dir) = &args.issues_dir { + let dir = std::path::Path::new(dir); + handle_command(&args, dir)?; + } else { + let branch = match &args.issues_branch { + Some(branch) => branch, + None => "entomologist-data", + }; + if !entomologist::git::git_branch_exists(branch)? { + entomologist::git::create_orphan_branch(branch)?; + } + let worktree = entomologist::git::Worktree::new(branch)?; + handle_command(&args, worktree.path())?; + } + + Ok(()) +} From 64022b16fa1ad1ec1569a76739c9d92944cc9b4a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 4 Jul 2025 00:51:12 -0600 Subject: [PATCH 6/6] add a Todo file, ironically --- Todo.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Todo.md diff --git a/Todo.md b/Todo.md new file mode 100644 index 0000000..56728ad --- /dev/null +++ b/Todo.md @@ -0,0 +1,30 @@ +# To do + +* migrate this todo list into entomologist + +* teach it to work with a git branch + - unpack the branch to a directory with `git worktree ${TMPDIR} ${BRANCH}` + - operate on the issues in that worktree + - git commit the result back to ${BRANCH} + - delete and prune the worktree + +* implement `ent new` + +* implement user control over state transitions + +* implement `ent comment ${ISSUE} [-m ${MESSAGE}]` + - each issue dir has a `comments` subdir + - each comment is identified by a sha1-style uid + - each comment is a file or directory under the `${ISSUE}/comments` + - comments are ordered by ctime? + +* implement `ent edit ${ISSUE} [-t ${TITLE}] [-d ${DESCRIPTION}]` + - or would it be better to put the title and description together into a new `message`, like git commits? + +* implement `ent edit ${COMMENT}` + +* implement `ent attach ${ISSUE} ${FILE}` + - each issue has its own independent namespace for attached files + - issue description & comments can reference attached files via standard md links + +* write a manpage