use anyhow::Context;
use color_print::cformat;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
use std::str::FromStr;
use strum::IntoEnumIterator;
use worktrunk::git::{HookType, Repository};
use worktrunk::path::{format_path_for_display, sanitize_for_filename};
use worktrunk::utils::epoch_now;
use crate::commands::hook_filter::HookSource;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::Display)]
#[strum(serialize_all = "kebab-case")]
pub enum InternalOp {
Remove,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookLog {
Hook {
source: HookSource,
hook_type: HookType,
name: String,
},
Internal(InternalOp),
}
impl HookLog {
pub fn hook(source: HookSource, hook_type: HookType, name: impl Into<String>) -> Self {
Self::Hook {
source,
hook_type,
name: name.into(),
}
}
pub fn internal(op: InternalOp) -> Self {
Self::Internal(op)
}
pub fn suffix(&self) -> String {
match self {
HookLog::Hook {
source,
hook_type,
name,
} => {
format!("{}-{}-{}", source, hook_type, sanitize_for_filename(name))
}
HookLog::Internal(op) => op.to_string(),
}
}
pub fn filename(&self, branch: &str) -> String {
let safe_branch = sanitize_for_filename(branch);
format!("{}-{}.log", safe_branch, self.suffix())
}
pub fn path(&self, log_dir: &Path, branch: &str) -> PathBuf {
log_dir.join(self.filename(branch))
}
pub fn to_spec(&self) -> String {
match self {
HookLog::Hook {
source,
hook_type,
name,
} => format!("{}:{}:{}", source, hook_type, name),
HookLog::Internal(op) => format!("internal:{}", op),
}
}
pub fn parse(s: &str) -> Result<Self, String> {
let parts: Vec<&str> = s.split(':').collect();
match parts.as_slice() {
["internal", op_str] => {
let op = InternalOp::from_str(op_str).map_err(|_| {
cformat!(
"Unknown internal operation: <bold>{}</>. Valid: remove",
op_str
)
})?;
Ok(Self::Internal(op))
}
[source_str, hook_type_str, name] if !name.is_empty() => {
let source = HookSource::from_str(source_str).map_err(|_| {
cformat!(
"Unknown source: <bold>{}</>. Valid: user, project",
source_str
)
})?;
let hook_type = HookType::from_str(hook_type_str).map_err(|_| {
let valid: Vec<_> = HookType::iter().map(|h| h.to_string()).collect();
cformat!(
"Unknown hook type: <bold>{}</>. Valid: {}",
hook_type_str,
valid.join(", ")
)
})?;
Ok(Self::Hook {
source,
hook_type,
name: (*name).to_string(),
})
}
_ => Err(cformat!(
"Invalid log spec: <bold>{}</>. Format: source:hook-type:name or internal:op",
s
)),
}
}
}
fn posix_command_separator(command: &str) -> &'static str {
if command.ends_with('\n') || command.ends_with(';') {
""
} else {
";"
}
}
fn create_detach_log(
repo: &Repository,
branch: &str,
hook_log: &HookLog,
) -> anyhow::Result<(PathBuf, fs::File)> {
let log_dir = repo.wt_logs_dir();
fs::create_dir_all(&log_dir).with_context(|| {
format!(
"Failed to create log directory {}",
format_path_for_display(&log_dir)
)
})?;
let log_path = hook_log.path(&log_dir, branch);
let log_file = fs::File::create(&log_path).with_context(|| {
format!(
"Failed to create log file {}",
format_path_for_display(&log_path)
)
})?;
Ok((log_path, log_file))
}
pub fn spawn_detached(
repo: &Repository,
worktree_path: &Path,
command: &str,
branch: &str,
hook_log: &HookLog,
context_json: Option<&str>,
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} (detached, logging to {})",
command,
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
spawn_detached_unix(worktree_path, command, log_file, context_json)?;
}
#[cfg(windows)]
{
spawn_detached_windows(worktree_path, command, log_file, context_json)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_unix(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
) -> anyhow::Result<()> {
use std::os::unix::process::CommandExt;
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
let shell_cmd = format!(
"{{ {}{} }} &",
full_command,
posix_command_separator(&full_command)
);
let mut child = Command::new("sh")
.arg("-c")
.arg(&shell_cmd)
.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.process_group(0) .spawn()
.context("Failed to spawn detached process")?;
child
.wait()
.context("Failed to wait for detachment shell")?;
Ok(())
}
#[cfg(windows)]
fn spawn_detached_windows(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
) -> anyhow::Result<()> {
use std::os::windows::process::CommandExt;
use worktrunk::shell_exec::ShellConfig;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let shell = ShellConfig::get()?;
let mut cmd = if shell.is_posix() {
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
shell.command(&full_command)
} else {
let full_command = match context_json {
Some(json) => {
let escaped_json = json.replace('\'', "''");
format!("'{}' | & {{ {} }}", escaped_json, command)
}
None => command.to_string(),
};
shell.command(&full_command)
};
cmd.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn()
.context("Failed to spawn detached process")?;
Ok(())
}
pub fn spawn_detached_exec(
repo: &Repository,
worktree_path: &Path,
program: &Path,
args: &[&str],
branch: &str,
hook_log: &HookLog,
stdin_bytes: &[u8],
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} {} (detached, logging to {})",
program.display(),
args.join(" "),
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
spawn_detached_exec_unix(worktree_path, program, args, log_file, stdin_bytes)?;
}
#[cfg(windows)]
{
spawn_detached_exec_windows(worktree_path, program, args, log_file, stdin_bytes)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_exec_unix(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut child = Command::new(program)
.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.process_group(0)
.spawn()
.context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
#[cfg(windows)]
fn spawn_detached_exec_windows(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let mut child = Command::new(program)
.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn()
.context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
pub fn generate_removing_path(trash_dir: &Path, worktree_path: &Path) -> PathBuf {
let timestamp = epoch_now();
let name = worktree_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
trash_dir.join(format!("{}-{}", name, timestamp))
}
pub fn build_remove_command_staged(
staged_path: &std::path::Path,
original_path: &std::path::Path,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let staged_path_str = staged_path.to_string_lossy();
let staged_escaped = escape(staged_path_str.as_ref().into());
if changed_directory {
let original_path_str = original_path.to_string_lossy();
let original_escaped = escape(original_path_str.as_ref().into());
format!(
"sleep 1 && rmdir -- {} 2>/dev/null; rm -rf -- {}",
original_escaped, staged_escaped
)
} else {
format!("rm -rf -- {}", staged_escaped)
}
}
pub fn build_remove_command(
worktree_path: &std::path::Path,
branch_to_delete: Option<&str>,
force_worktree: bool,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let worktree_path_str = worktree_path.to_string_lossy();
let worktree_escaped = escape(worktree_path_str.as_ref().into());
let stop_fsmonitor = format!(
"{{ git -C {} fsmonitor--daemon stop 2>/dev/null || true; }}",
worktree_escaped
);
let force_flag = if force_worktree { " --force" } else { "" };
let prefix = if changed_directory {
format!("sleep 1 && {} && ", stop_fsmonitor)
} else {
format!("{} && ", stop_fsmonitor)
};
match branch_to_delete {
Some(branch_name) => {
let branch_escaped = escape(branch_name.into());
format!(
"{}git worktree remove{} {} && git branch -D {}",
prefix, force_flag, worktree_escaped, branch_escaped
)
}
None => {
format!(
"{}git worktree remove{} {}",
prefix, force_flag, worktree_escaped
)
}
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
#[test]
fn test_sanitize_for_filename() {
assert_snapshot!(
[
("path separator /", sanitize_for_filename("feature/branch")),
("path separator \\", sanitize_for_filename("feature\\branch")),
("colon", sanitize_for_filename("bug:123")),
("angle brackets", sanitize_for_filename("fix<angle>")),
("pipe", sanitize_for_filename("fix|pipe")),
("question mark", sanitize_for_filename("fix?question")),
("wildcard", sanitize_for_filename("fix*wildcard")),
("quotes", sanitize_for_filename("fix\"quotes\"")),
("multiple special", sanitize_for_filename("a/b\\c<d>e:f\"g|h?i*j")),
("already safe", sanitize_for_filename("normal-branch")),
("underscore", sanitize_for_filename("branch_with_underscore")),
("reserved prefix CONSOLE", sanitize_for_filename("CONSOLE")),
("reserved prefix COM10", sanitize_for_filename("COM10")),
]
.into_iter()
.map(|(label, val)| format!("{label}: {val}"))
.collect::<Vec<_>>()
.join("\n"),
@r"
path separator /: feature-branch-30k
path separator \: feature-branch-k37
colon: bug-123-4xh
angle brackets: fix-angle-q9m
pipe: fix-pipe-68k
question mark: fix-question-ab6
wildcard: fix-wildcard-38y
quotes: fix-quotes-2xu
multiple special: a-b-c-d-e-f-g-h-i-j-obi
already safe: normal-branch-83y
underscore: branch_with_underscore-b65
reserved prefix CONSOLE: CONSOLE-8fv
reserved prefix COM10: COM10-1s2
"
);
for name in [
"CON", "con", "PRN", "AUX", "NUL", "COM0", "COM1", "com9", "LPT0", "LPT1", "lpt9",
] {
let result = sanitize_for_filename(name);
assert!(!result.is_empty() && result.len() > 3, "{name} -> {result}");
}
let a = sanitize_for_filename("feature/x");
let b = sanitize_for_filename("feature-x");
assert_ne!(a, b, "should not collide: {a} vs {b}");
}
#[test]
fn test_posix_command_separator() {
assert_eq!(posix_command_separator("echo hello\n"), "");
assert_eq!(posix_command_separator("echo hello;"), "");
assert_eq!(posix_command_separator("echo hello"), ";");
assert_eq!(posix_command_separator(""), ";");
assert_eq!(posix_command_separator("echo\nhello"), ";");
assert_eq!(posix_command_separator("echo; hello"), ";");
}
#[test]
fn test_build_remove_command() {
use std::path::PathBuf;
let path = PathBuf::from("/tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, None, false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, true, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove --force /tmp/test-worktree");
let special_path = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command(&special_path, Some("feature/branch"), false, true), @"sleep 1 && { git -C '/tmp/test worktree' fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove '/tmp/test worktree' && git branch -D feature/branch");
}
#[test]
fn test_generate_removing_path() {
let trash_dir = PathBuf::from("/tmp/repo/.git/wt/trash");
let path = PathBuf::from("/tmp/my-project.feature");
let removing_path = generate_removing_path(&trash_dir, &path);
assert_eq!(removing_path.parent(), Some(trash_dir.as_path()));
let name = removing_path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("my-project.feature-"), "got: {}", name);
let timestamp_part = name.trim_start_matches("my-project.feature-");
assert!(
timestamp_part.chars().all(|c| c.is_ascii_digit()),
"timestamp part should be numeric: {}",
timestamp_part
);
}
#[test]
fn test_build_remove_command_staged() {
let staged_path = PathBuf::from("/tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let original_path = PathBuf::from("/tmp/my-project.feature");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, true), @"sleep 1 && rmdir -- /tmp/my-project.feature 2>/dev/null; rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, false), @"rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let special_path = PathBuf::from("/tmp/repo/.git/wt/trash/test worktree-123");
let special_original = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command_staged(&special_path, &special_original, true), @"sleep 1 && rmdir -- '/tmp/test worktree' 2>/dev/null; rm -rf -- '/tmp/repo/.git/wt/trash/test worktree-123'");
}
#[test]
fn test_hook_log_suffix() {
use worktrunk::git::HookType;
assert_snapshot!(HookLog::hook(HookSource::User, HookType::PostStart, "server").suffix(), @"user-post-start-server-f4t");
assert_snapshot!(HookLog::hook(HookSource::Project, HookType::PreStart, "build").suffix(), @"project-pre-start-build-seq");
assert_snapshot!(HookLog::hook(HookSource::User, HookType::PreRemove, "cleanup").suffix(), @"user-pre-remove-cleanup-non");
assert_snapshot!(HookLog::parse("user:post-start:server").unwrap().suffix(), @"user-post-start-server-f4t");
assert_snapshot!(HookLog::parse("project:pre-start:build").unwrap().suffix(), @"project-pre-start-build-seq");
assert_eq!(HookLog::internal(InternalOp::Remove).suffix(), "remove");
}
#[test]
fn test_hook_log_filename() {
use worktrunk::git::HookType;
let log = HookLog::hook(HookSource::User, HookType::PostStart, "server");
assert_snapshot!(log.filename("main"), @"main-vfz-user-post-start-server-f4t.log");
assert_snapshot!(log.filename("feature/auth"), @"feature-auth-j34-user-post-start-server-f4t.log");
assert_snapshot!(HookLog::internal(InternalOp::Remove).filename("main"), @"main-vfz-remove.log");
}
#[test]
fn test_hook_log_parse_internal() {
let log = HookLog::parse("internal:remove").unwrap();
assert_eq!(log, HookLog::Internal(InternalOp::Remove));
assert_eq!(log.suffix(), "remove");
}
#[test]
fn test_hook_log_parse_errors() {
assert_snapshot!(HookLog::parse("invalid:post-start:server").unwrap_err(), @"Unknown source: [1minvalid[22m. Valid: user, project");
assert_snapshot!(HookLog::parse("user:invalid-hook:server").unwrap_err(), @"Unknown hook type: [1minvalid-hook[22m. Valid: pre-switch, post-switch, pre-start, post-start, pre-commit, post-commit, pre-merge, post-merge, pre-remove, post-remove");
assert_snapshot!(HookLog::parse("internal:unknown").unwrap_err(), @"Unknown internal operation: [1munknown[22m. Valid: remove");
assert_snapshot!(HookLog::parse("remove").unwrap_err(), @"Invalid log spec: [1mremove[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("foo:bar").unwrap_err(), @"Invalid log spec: [1mfoo:bar[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:").unwrap_err(), @"Invalid log spec: [1muser:[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:post-start:my:server").unwrap_err(), @"Invalid log spec: [1muser:post-start:my:server[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:post-start:").unwrap_err(), @"Invalid log spec: [1muser:post-start:[22m. Format: source:hook-type:name or internal:op");
}
#[test]
fn test_hook_log_roundtrip() {
use worktrunk::git::HookType;
let created = HookLog::hook(HookSource::User, HookType::PostStart, "server");
let parsed = HookLog::parse("user:post-start:server").unwrap();
assert_eq!(created.filename("main"), parsed.filename("main"));
let created = HookLog::internal(InternalOp::Remove);
let parsed = HookLog::parse("internal:remove").unwrap();
assert_eq!(created.filename("main"), parsed.filename("main"));
}
#[test]
fn test_hook_log_to_spec_roundtrip() {
use worktrunk::git::HookType;
let original = HookLog::hook(HookSource::User, HookType::PostStart, "server");
let spec = original.to_spec();
assert_eq!(spec, "user:post-start:server");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
let original = HookLog::hook(HookSource::Project, HookType::PreMerge, "lint");
let spec = original.to_spec();
assert_eq!(spec, "project:pre-merge:lint");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
let original = HookLog::internal(InternalOp::Remove);
let spec = original.to_spec();
assert_eq!(spec, "internal:remove");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
}
}