Compare commits

...

6 commits

Author SHA1 Message Date
64022b16fa add a Todo file, ironically 2025-07-05 21:20:48 -06:00
26c98591b5 start adding ent binary 2025-07-05 21:20:44 -06:00
e8910b906a 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.
2025-07-05 21:20:16 -06:00
d94c991eaa add dependency tracking to issue type 2025-07-05 21:19:18 -06:00
16c6288cee 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?
2025-07-05 21:18:36 -06:00
b9979f5e9e start adding Issue struct
This abstracts a single issue.
2025-07-05 21:18:25 -06:00
27 changed files with 644 additions and 1 deletions

View file

@ -4,3 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [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"
thiserror = "2.0.11"
toml = "0.8.19"

30
Todo.md Normal file
View file

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

77
src/bin/ent/main.rs Normal file
View file

@ -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<String>,
/// Branch containing issues.
#[arg(short = 'b', long)]
issues_branch: Option<String>,
/// Type of behavior/output.
#[command(subcommand)]
command: Commands,
}
#[derive(clap::Subcommand, Debug)]
enum Commands {
/// List issues.
List,
/// Create a new issue.
New {
title: Option<String>,
description: Option<String>,
},
}
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(())
}

200
src/git.rs Normal file
View file

@ -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<Worktree, GitError> {
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<bool, GitError> {
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);
}
}

125
src/issue.rs Normal file
View file

@ -0,0 +1,125 @@
use std::str::FromStr;
#[derive(Debug, PartialEq, serde::Deserialize)]
/// These are the states an issue can be in.
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<String>,
pub state: State,
pub dependencies: Option<Vec<IssueHandle>>,
}
#[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<Self, Self::Err> {
let s = s.to_lowercase();
if s == "new" {
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" {
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<Self, ReadIssueError> {
let mut title: Option<String> = None;
let mut description: Option<String> = None;
let mut state = State::New; // default state, if not specified in the issue
let mut dependencies: Option<Vec<String>> = None;
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 if file_name == "dependencies" {
let dep_strings = std::fs::read_to_string(direntry.path())?;
let deps: Vec<IssueHandle> = 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);
}
}
}
if title == None {
return Err(ReadIssueError::IssueParseError);
}
Ok(Self {
title: title.unwrap(),
description: description,
state: state,
dependencies,
})
}
}
#[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,
dependencies: None,
};
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,
dependencies: None,
};
assert_eq!(issue, expected);
}
}

170
src/issues.rs Normal file
View file

@ -0,0 +1,170 @@
// 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<String, crate::issue::Issue>,
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<Self, ReadIssuesError> {
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,
dependencies: None,
},
);
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,
dependencies: None,
}
);
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,
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,
},
);
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);
}
}

View file

@ -1 +1,3 @@
pub mod git;
pub mod issue;
pub mod issues;

View file

@ -0,0 +1,4 @@
This is the description of my issue.
It is multiple lines.
* Arbitrary contents
* But let's use markdown by convention

View file

@ -0,0 +1 @@
this is the title of my issue

View file

@ -0,0 +1 @@
inprogress

View file

@ -0,0 +1 @@
minimal

View file

@ -0,0 +1 @@
done

View file

@ -0,0 +1 @@
oh yeah we got titles

1
test/0001/config.toml Normal file
View file

@ -0,0 +1 @@
states = [ "open", "closed" ]

View file

@ -0,0 +1,4 @@
Lots of words
that don't say much
because this is just
a test

View file

@ -0,0 +1 @@
wontdo

View file

@ -0,0 +1 @@
issues out the wazoo

View file

@ -0,0 +1 @@
done

View file

@ -0,0 +1 @@
oh yeah we got titles

View file

@ -0,0 +1,2 @@
3fa5bfd93317ad25772680071d5ac3259cd2384f
dd79c8cfb8beeacd0460429944b4ecbe95a31561

View file

@ -0,0 +1,3 @@
a test has begun
for dependencies we seek
intertwining life

View file

@ -0,0 +1 @@
wontdo

View file

@ -0,0 +1 @@
issue with dependencies

1
test/0002/config.toml Normal file
View file

@ -0,0 +1 @@
states = [ "open", "closed" ]

View file

@ -0,0 +1,4 @@
Lots of words
that don't say much
because this is just
a test

View file

@ -0,0 +1 @@
wontdo

View file

@ -0,0 +1 @@
issues out the wazoo