use std::{
path::PathBuf,
process::{Command, Stdio},
time::Instant,
};
use anyhow::{Result, anyhow};
use repo::{Repository, ThreadManager};
use serde::Serialize;
use super::{
merge::merge_thread_into_current,
snapshot::{SnapshotAgentOverrides, create_snapshot},
thread::start_thread,
thread_cmd::{DropOutcome, drop_thread_silent},
};
use crate::{
cli::{Cli, ThreadStartArgs, TryArgs, WorkspaceModeArg, should_output_json, style},
config::UserConfig,
};
#[derive(Debug, Serialize)]
struct TryOutput {
status: &'static str,
action: &'static str,
message: String,
thread: String,
thread_dropped: bool,
#[serde(skip_serializing_if = "Option::is_none")]
cleanup_error: Option<String>,
exit_code: Option<i32>,
duration_ms: u128,
#[serde(skip_serializing_if = "Option::is_none")]
captured_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
merge_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
next_action: Option<String>,
}
pub fn cmd_try(cli: &Cli, args: TryArgs) -> Result<()> {
if args.command.is_empty() {
return Err(anyhow!("Usage: heddle try -- <cmd...>"));
}
let repo_root_arg = cli
.repo
.as_ref()
.cloned()
.unwrap_or(std::env::current_dir()?);
let repo = Repository::open(&repo_root_arg)?;
let parent_head_before = repo.head()?.map(|id| id.to_string_full());
let thread_name = args
.name
.clone()
.unwrap_or_else(|| default_try_name(&args.command));
if args.name.is_some() && thread_name_in_use(&repo, &thread_name)? {
return Err(anyhow!(
"thread '{thread_name}' already exists; pick a different --name or omit it for an auto-generated name"
));
}
let workspace = match args.workspace {
WorkspaceModeArg::Auto => WorkspaceModeArg::Heavy,
other => other,
};
let start_args = ThreadStartArgs {
name: thread_name.clone(),
from: None,
path: None,
workspace,
agent_provider: None,
agent_model: None,
task: Some(format!("try: {}", display_cmd(&args.command))),
parent_thread: None,
automated: true,
print_cd_path: false,
daemon: true,
no_daemon: false,
shared_target: false,
};
let start_output = start_thread(&repo, start_args)?;
let thread_path = start_output
.execution_path
.as_ref()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("Could not determine ephemeral thread checkout path"))?;
let started = Instant::now();
let status = Command::new(&args.command[0])
.args(&args.command[1..])
.current_dir(&thread_path)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
let duration_ms = started.elapsed().as_millis();
let exit = match status {
Ok(status) => status,
Err(err) => {
let _ = drop_thread_silent(&repo, &thread_name, true);
return Err(anyhow!(
"Failed to execute `{}`: {}",
display_cmd(&args.command),
err
));
}
};
let exit_code = exit.code();
if !exit.success() {
let drop_result = drop_thread_silent(&repo, &thread_name, true);
let (thread_dropped, cleanup_error) =
interpret_drop_result(&thread_name, drop_result, "try cleanup");
verify_parent_unchanged(&repo, parent_head_before.as_deref())?;
let drop_msg = if thread_dropped {
format!("thread '{thread_name}' dropped")
} else {
format!("thread '{thread_name}' NOT dropped (cleanup failed)")
};
let output = TryOutput {
status: "failed",
action: "try",
message: format!(
"`{}` failed (exit {}); {}",
display_cmd(&args.command),
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
drop_msg
),
thread: thread_name.clone(),
thread_dropped,
cleanup_error,
exit_code,
duration_ms,
captured_state: None,
merge_state: None,
next_action: None,
};
emit(cli, &repo, &output)?;
std::process::exit(exit_code.unwrap_or(1));
}
let thread_repo = Repository::open(&thread_path)?;
let user_config = UserConfig::load_default().unwrap_or_default();
let intent = format!("try: {}", display_cmd(&args.command));
let confidence = if args.auto_merge { 0.9 } else { 0.85 };
let snapshot = create_snapshot(
&thread_repo,
&user_config,
Some(intent),
Some(confidence),
SnapshotAgentOverrides {
provider: None,
model: None,
session: None,
segment: None,
policy: None,
no_policy: false,
no_agent: false,
},
);
let captured_state = match snapshot {
Ok(out) => Some(out.change_id),
Err(err) => {
tracing::warn!(error = %err, "capture failed in try thread; leaving thread in place");
None
}
};
let mut merge_state: Option<String> = None;
let mut thread_dropped = false;
let mut cleanup_error: Option<String> = None;
if args.auto_merge && captured_state.is_some() {
let merge_output = merge_thread_into_current(
&repo,
&thread_name,
Some(format!("try: {}", display_cmd(&args.command))),
false,
false,
true,
false,
false,
)?;
merge_state = merge_output.merge_state.clone();
if !args.keep_on_success && merge_output.conflicts.is_empty() {
let drop_result = drop_thread_silent(&repo, &thread_name, true);
let (dropped, err) =
interpret_drop_result(&thread_name, drop_result, "auto-merge cleanup");
thread_dropped = dropped;
cleanup_error = err;
}
}
if !args.auto_merge {
verify_parent_unchanged(&repo, parent_head_before.as_deref())?;
}
let next_action = if !args.auto_merge {
Some(format!(
"heddle merge {} (or `heddle thread drop {}` to discard)",
thread_name, thread_name
))
} else {
None
};
let message = if args.auto_merge {
match (&captured_state, &merge_state) {
(Some(state), Some(merge)) => format!(
"`{}` succeeded; captured {}, merged into parent as {}",
display_cmd(&args.command),
state,
merge
),
(Some(state), None) => format!(
"`{}` succeeded; captured {}, merge into parent skipped",
display_cmd(&args.command),
state
),
_ => format!(
"`{}` succeeded; nothing to capture",
display_cmd(&args.command)
),
}
} else {
match &captured_state {
Some(state) => format!(
"`{}` succeeded; thread '{}' ready (state {}). Run `heddle merge {}` to land.",
display_cmd(&args.command),
thread_name,
state,
thread_name
),
None => format!(
"`{}` succeeded; thread '{}' ready (no capture).",
display_cmd(&args.command),
thread_name
),
}
};
let output = TryOutput {
status: "completed",
action: "try",
message,
thread: thread_name,
thread_dropped,
cleanup_error,
exit_code,
duration_ms,
captured_state,
merge_state,
next_action,
};
emit(cli, &repo, &output)
}
pub(crate) fn thread_name_in_use(repo: &Repository, name: &str) -> Result<bool> {
let manager = ThreadManager::new(repo.heddle_dir());
if manager.find_by_thread(name)?.is_some() || manager.load(name)?.is_some() {
return Ok(true);
}
if repo.refs().get_thread(name)?.is_some() {
return Ok(true);
}
Ok(false)
}
fn verify_parent_unchanged(repo: &Repository, before: Option<&str>) -> Result<()> {
let after = repo.head()?.map(|id| id.to_string_full());
let after_ref = after.as_deref();
if before != after_ref {
return Err(anyhow!(
"internal error: parent HEAD drifted during `heddle try` (before={:?} after={:?}); please file a bug",
before,
after_ref
));
}
Ok(())
}
fn display_cmd(cmd: &[String]) -> String {
cmd.join(" ")
}
fn default_try_name(command: &[String]) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
for arg in command {
arg.hash(&mut hasher);
}
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
.hash(&mut hasher);
let digest = hasher.finish();
format!("try-{:08x}", digest as u32)
}
fn emit(cli: &Cli, repo: &Repository, output: &TryOutput) -> Result<()> {
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(output)?);
} else {
let painted = match output.status {
"completed" => style::accent(&output.message),
_ => style::warn(&output.message),
};
println!("{}", painted);
if let Some(next) = &output.next_action {
println!("Next: {}", style::bold(next));
}
}
Ok(())
}
fn interpret_drop_result(
thread_name: &str,
result: Result<DropOutcome>,
context: &str,
) -> (bool, Option<String>) {
match result {
Ok(_) => (true, None),
Err(err) => {
let msg = err.to_string();
tracing::warn!(thread = %thread_name, error = %err, context, "drop failed");
eprintln!(
"warning: failed to drop ephemeral thread '{thread_name}' during {context}: {msg}"
);
(false, Some(msg))
}
}
}
#[cfg(test)]
mod tests {
use objects::object::ChangeId;
use super::*;
fn init_repo() -> (tempfile::TempDir, Repository) {
let temp = tempfile::TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
(temp, repo)
}
#[test]
fn thread_name_in_use_returns_false_for_unknown_name() {
let (_temp, repo) = init_repo();
assert!(!thread_name_in_use(&repo, "no-such-thread").unwrap());
}
#[test]
fn thread_name_in_use_detects_ref_only_thread() {
let (_temp, repo) = init_repo();
let id = ChangeId::generate();
repo.refs().set_thread("ref-only-thread", &id).unwrap();
let manager = ThreadManager::new(repo.heddle_dir());
assert!(manager.find_by_thread("ref-only-thread").unwrap().is_none());
assert!(manager.load("ref-only-thread").unwrap().is_none());
assert!(thread_name_in_use(&repo, "ref-only-thread").unwrap());
}
#[test]
fn cmd_try_refuses_name_collision_via_ref_only_thread() {
let (_temp, repo) = init_repo();
let id = ChangeId::generate();
repo.refs().set_thread("legacy-ref-thread", &id).unwrap();
let make_args = || TryArgs {
name: Some("legacy-ref-thread".into()),
workspace: WorkspaceModeArg::Heavy,
auto_merge: false,
keep_on_success: false,
command: vec!["true".into()],
};
let cli = Cli {
command: crate::cli::Commands::Try(make_args()),
json: false,
output: None,
no_color: true,
repo: Some(repo.root().to_path_buf()),
verbose: 0,
quiet: false,
op_id: None,
};
let err = cmd_try(&cli, make_args()).expect_err("must refuse ref-only collision");
let msg = err.to_string();
assert!(
msg.contains("legacy-ref-thread") && msg.contains("already exists"),
"expected precise collision message; got: {msg}"
);
}
#[test]
fn interpret_drop_result_ok_marks_dropped_with_no_cleanup_error() {
let (dropped, cleanup_error) =
interpret_drop_result("ephemeral-x", Ok(DropOutcome::Deleted), "try cleanup");
assert!(dropped);
assert!(cleanup_error.is_none());
}
#[test]
fn interpret_drop_result_err_marks_not_dropped_and_carries_message() {
let err: Result<DropOutcome> = Err(anyhow!(
"simulated lock contention on .heddle/locks/threads"
));
let (dropped, cleanup_error) = interpret_drop_result("ephemeral-x", err, "try cleanup");
assert!(!dropped, "thread_dropped must be false when cleanup fails");
let msg = cleanup_error.expect("cleanup_error must carry the failure message");
assert!(
msg.contains("simulated lock contention"),
"cleanup_error should include the underlying message; got: {msg}"
);
}
#[test]
fn try_output_serializes_cleanup_error_only_when_present() {
let ok_output = TryOutput {
status: "failed",
action: "try",
message: "ok-path".into(),
thread: "t".into(),
thread_dropped: true,
cleanup_error: None,
exit_code: Some(1),
duration_ms: 0,
captured_state: None,
merge_state: None,
next_action: None,
};
let json = serde_json::to_string(&ok_output).unwrap();
assert!(
!json.contains("cleanup_error"),
"field must be skipped when None: {json}"
);
assert!(json.contains("\"thread_dropped\":true"));
let err_output = TryOutput {
status: "failed",
action: "try",
message: "err-path".into(),
thread: "t".into(),
thread_dropped: false,
cleanup_error: Some("lock held".into()),
exit_code: Some(1),
duration_ms: 0,
captured_state: None,
merge_state: None,
next_action: None,
};
let json = serde_json::to_string(&err_output).unwrap();
assert!(
json.contains("\"thread_dropped\":false"),
"thread_dropped must be false when cleanup failed: {json}"
);
assert!(
json.contains("\"cleanup_error\":\"lock held\""),
"cleanup_error must surface the message: {json}"
);
}
}