Compare commits

...

11 commits

Author SHA1 Message Date
seb
6cff355e4a Merge pull request 'empty-descriptions-and-dropped-worktrees' (#15) from empty-descriptions-and-dropped-worktrees into main
Reviewed-on: #15
2025-07-10 09:48:21 -06:00
e09e4b9cb7 simplify ent new 2025-07-09 22:36:24 -06:00
acf539c683 handle user abort in ent comment
The user saving an empty description is a normal user-initiated abort,
not an error.
2025-07-09 22:36:24 -06:00
a199fbc7f7 handle aborts in ent edit ISSUE
The user saving an empty description file is a normal user-initiated
abort, not an error.
2025-07-09 22:36:24 -06:00
fc658009f5 Comment: handle empty description 2025-07-09 22:34:55 -06:00
211bf92dde Issue: handle empty description from user
This fixes issue a26da230276d317e85f9fcca41c19d2e.
2025-07-09 22:33:58 -06:00
bfdf6178f4 add git::commit() 2025-07-09 22:33:58 -06:00
16de030b8e add git::add_file() 2025-07-09 22:33:58 -06:00
ac72251e0e add git::restore_file()
This restores a file from the index to the worktree.
2025-07-09 22:33:58 -06:00
1509c42734 add git::worktree_is_dirty()
This returns Ok(true) if the worktree has any modified files (staged or
unstaged), or any added (staged) files.  Ok(false) if not.

Ignores untracked files.
2025-07-09 22:33:58 -06:00
ca353352f8 git::Worktree::drop() now force-drops the worktree
This avoids leaving prunable worktrees around if we dirtied the worktree
and didn't commit.

This can happen in the following situation:

1. User runs `ent new`.

2. ent creates a new directory for the issue.

3. ent opens an editor to let the user type in the description of the
   new issue.  The editor saves to `ISSUE/description`.

4. User changes their mind and no longer wants to make a new issue, so
   they save an empty buffer and exit the editor.

5. ent sees that the file is empty, and returns an error from
   Issue::edit_description().

6. ent propagates the error up through program exit, and eventually
   the git::Worktree struct is dropped.  Since the worktree is dirty
   (it has the new issue dir with an empty description file in it),
   `git worktree remove` fails.

But `git worktree remove --force` works!
2025-07-09 22:31:07 -06:00
4 changed files with 169 additions and 25 deletions

View file

@ -132,27 +132,40 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<(
}
}
Commands::New {
description: Some(description),
} => {
Commands::New { description } => {
let mut issue = entomologist::issue::Issue::new(issues_dir)?;
issue.set_description(description)?;
let r = match description {
Some(description) => issue.set_description(description),
None => issue.edit_description(),
};
match r {
Err(entomologist::issue::IssueError::EmptyDescription) => {
println!("no new issue created");
return Ok(());
}
Err(e) => {
return Err(e.into());
}
Ok(()) => {
println!("created new issue '{}'", issue.title());
}
Commands::New { description: None } => {
let mut issue = entomologist::issue::Issue::new(issues_dir)?;
issue.edit_description()?;
println!("created new issue '{}'", issue.title());
}
}
Commands::Edit { issue_id } => {
let mut issues =
entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?;
match issues.get_mut_issue(issue_id) {
Some(issue) => {
issue.edit_description()?;
Some(issue) => match issue.edit_description() {
Err(entomologist::issue::IssueError::EmptyDescription) => {
println!("aborted issue edit");
return Ok(());
}
Err(e) => {
return Err(e.into());
}
Ok(()) => (),
},
None => {
return Err(anyhow::anyhow!("issue {} not found", issue_id));
}
@ -227,12 +240,20 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<(
return Err(anyhow::anyhow!("issue {} not found", issue_id));
};
let mut comment = issue.new_comment()?;
match description {
Some(description) => {
comment.set_description(description)?;
let r = match description {
Some(description) => comment.set_description(description),
None => comment.edit_description(),
};
match r {
Err(entomologist::comment::CommentError::EmptyDescription) => {
println!("aborted new comment");
return Ok(());
}
None => {
comment.edit_description()?;
Err(e) => {
return Err(e.into());
}
Ok(()) => {
println!("created new comment {}", &comment.uuid);
}
}
}

View file

@ -22,6 +22,8 @@ pub enum CommentError {
GitError(#[from] crate::git::GitError),
#[error("Failed to run editor")]
EditorError,
#[error("supplied description is empty")]
EmptyDescription,
}
impl Comment {
@ -60,6 +62,9 @@ impl Comment {
}
pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> {
if description.len() == 0 {
return Err(CommentError::EmptyDescription);
}
self.description = String::from(description);
let mut description_filename = std::path::PathBuf::from(&self.dir);
description_filename.push("description");
@ -79,6 +84,7 @@ impl Comment {
pub fn edit_description(&mut self) -> Result<(), CommentError> {
let mut description_filename = std::path::PathBuf::from(&self.dir);
description_filename.push("description");
let exists = description_filename.exists();
let result = std::process::Command::new("vi")
.arg(&description_filename.as_mut_os_str())
.spawn()?
@ -88,8 +94,31 @@ impl Comment {
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap());
return Err(CommentError::EditorError);
}
crate::git::git_commit_file(&description_filename)?;
if description_filename.exists() && description_filename.metadata()?.len() > 0 {
crate::git::add_file(&description_filename)?;
} else {
// User saved an empty file, which means they changed their
// mind and no longer want to edit the description.
if exists {
crate::git::restore_file(&description_filename)?;
}
return Err(CommentError::EmptyDescription);
}
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? {
crate::git::commit(
&description_filename.parent().unwrap(),
&format!(
"new description for comment {}",
description_filename
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
),
)?;
self.read_description()?;
}
Ok(())
}
}

View file

@ -20,9 +20,24 @@ pub struct Worktree {
impl Drop for Worktree {
fn drop(&mut self) {
let _result = std::process::Command::new("git")
.args(["worktree", "remove", &self.path.path().to_string_lossy()])
let result = std::process::Command::new("git")
.args([
"worktree",
"remove",
"--force",
&self.path.path().to_string_lossy(),
])
.output();
match result {
Err(e) => {
println!("failed to run git: {:#?}", e);
}
Ok(result) => {
if !result.status.success() {
println!("failed to remove git worktree: {:#?}", result);
}
}
}
}
}
@ -91,6 +106,56 @@ pub fn git_branch_exists(branch: &str) -> Result<bool, GitError> {
return Ok(result.status.success());
}
pub fn worktree_is_dirty(dir: &str) -> Result<bool, GitError> {
// `git status --porcelain` prints a terse list of files added or
// modified (both staged and not), and new untracked files. So if
// says *anything at all* it means the worktree is dirty.
let result = std::process::Command::new("git")
.args(["status", "--porcelain", "--untracked-files=no"])
.current_dir(dir)
.output()?;
return Ok(result.stdout.len() > 0);
}
pub fn add_file(file: &std::path::Path) -> Result<(), GitError> {
let result = std::process::Command::new("git")
.args(["add", &file.to_string_lossy()])
.current_dir(file.parent().unwrap())
.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);
}
return Ok(());
}
pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> {
let result = std::process::Command::new("git")
.args(["restore", &file.to_string_lossy()])
.current_dir(file.parent().unwrap())
.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);
}
return Ok(());
}
pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> {
let result = std::process::Command::new("git")
.args(["commit", "-m", msg])
.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 git_commit_file(file: &std::path::Path) -> Result<(), GitError> {
let mut git_dir = std::path::PathBuf::from(file);
git_dir.pop();

View file

@ -47,6 +47,8 @@ pub enum IssueError {
GitError(#[from] crate::git::GitError),
#[error("Failed to run editor")]
EditorError,
#[error("supplied description is empty")]
EmptyDescription,
}
impl FromStr for State {
@ -195,6 +197,9 @@ impl Issue {
}
pub fn set_description(&mut self, description: &str) -> Result<(), IssueError> {
if description.len() == 0 {
return Err(IssueError::EmptyDescription);
}
self.description = String::from(description);
let mut description_filename = std::path::PathBuf::from(&self.dir);
description_filename.push("description");
@ -214,6 +219,7 @@ impl Issue {
pub fn edit_description(&mut self) -> Result<(), IssueError> {
let mut description_filename = std::path::PathBuf::from(&self.dir);
description_filename.push("description");
let exists = description_filename.exists();
let result = std::process::Command::new("vi")
.arg(&description_filename.as_mut_os_str())
.spawn()?
@ -223,8 +229,31 @@ impl Issue {
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap());
return Err(IssueError::EditorError);
}
crate::git::git_commit_file(&description_filename)?;
if description_filename.exists() && description_filename.metadata()?.len() > 0 {
crate::git::add_file(&description_filename)?;
} else {
// User saved an empty file, which means they changed their
// mind and no longer want to edit the description.
if exists {
crate::git::restore_file(&description_filename)?;
}
return Err(IssueError::EmptyDescription);
}
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? {
crate::git::commit(
&description_filename.parent().unwrap(),
&format!(
"new description for issue {}",
description_filename
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
),
)?;
self.read_description()?;
}
Ok(())
}