Skip to content

Commit 6c35fbe

Browse files
neumieclaude
andcommitted
feat: add env vars to terminal hooks and fix on_worktree_close branch
Terminal hooks (on_create, shell_wrapper) previously received zero environment variables. Now they export OKENA_PROJECT_ID, OKENA_PROJECT_NAME, OKENA_PROJECT_PATH, and OKENA_BRANCH (for worktree projects) into the shell session so they persist after the hook command runs. Also adds OKENA_TERMINAL_NAME and OKENA_BRANCH to terminal.on_close, and fixes on_worktree_close being the only worktree hook missing OKENA_BRANCH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 76421f9 commit 6c35fbe

File tree

7 files changed

+228
-38
lines changed

7 files changed

+228
-38
lines changed

crates/okena-views-terminal/src/layout/terminal_pane/mod.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> {
359359
&settings.default_shell,
360360
);
361361

362-
let (project_path, _project_name, project_hooks, parent_hooks) = {
362+
// Read fresh path and project info from workspace state
363+
let (project_path, project_name, project_hooks, parent_hooks, is_worktree) = {
363364
let project = ws.project(&self.project_id);
364365
let path = project.map(|p| p.path.clone())
365366
.unwrap_or_else(|| self.project_path.clone());
@@ -369,19 +370,21 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> {
369370
.and_then(|p| p.worktree_info.as_ref())
370371
.and_then(|wt| ws.project(&wt.parent_project_id))
371372
.map(|p| p.hooks.clone());
372-
(path, name, hooks_cfg, parent)
373+
let is_wt = project.map(|p| p.worktree_info.is_some()).unwrap_or(false);
374+
(path, name, hooks_cfg, parent, is_wt)
373375
};
374376

375-
// Apply shell_wrapper if configured - need global hooks from settings
376-
// This requires accessing the main app's settings which we don't have directly.
377-
// The hooks are resolved through okena_workspace which reads from workspace state.
377+
let env = hooks::terminal_hook_env(&self.project_id, &project_name, &project_path, is_worktree);
378+
379+
// Apply shell_wrapper if configured
378380
let global_hooks = settings.hooks;
379381
if let Some(wrapper) = hooks::resolve_shell_wrapper(&project_hooks, parent_hooks.as_ref(), &global_hooks) {
380-
shell = hooks::apply_shell_wrapper(&shell, &wrapper);
382+
shell = hooks::apply_shell_wrapper(&shell, &wrapper, &env);
381383
}
382384

385+
// Apply on_create: wrap shell to run command first, then exec into shell
383386
if let Some(cmd) = hooks::resolve_terminal_on_create_simple(&project_hooks, parent_hooks.as_ref(), &global_hooks) {
384-
shell = hooks::apply_on_create(&shell, &cmd);
387+
shell = hooks::apply_on_create(&shell, &cmd, &env);
385388
}
386389

387390
match self

crates/okena-workspace/src/actions/project.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,14 +644,17 @@ impl Workspace {
644644
// for backwards compatibility with worktrees created before monorepo support
645645
let worktree_path = std::path::PathBuf::from(&project_path);
646646

647+
// Resolve branch BEFORE removal (git worktree remove deletes the checkout)
648+
let branch = okena_git::get_current_branch(&worktree_path).unwrap_or_default();
649+
647650
// Remove the git worktree
648651
okena_git::remove_worktree(&worktree_path, force)?;
649652

650653
// Delete the project from workspace (this also fires on_project_close)
651654
self.delete_project(project_id, global_hooks, cx);
652655

653656
// Fire worktree-specific hook (runs headlessly since project is deleted)
654-
hooks::fire_on_worktree_close(&project_hooks, project_id, &project_name, &project_path, global_hooks, cx);
657+
hooks::fire_on_worktree_close(&project_hooks, project_id, &project_name, &project_path, &branch, global_hooks, cx);
655658

656659
Ok(())
657660
}

crates/okena-workspace/src/hooks.rs

Lines changed: 171 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,67 @@ fn is_valid_env_key(key: &str) -> bool {
160160
bytes[1..].iter().all(|&b| b.is_ascii_alphanumeric() || b == b'_')
161161
}
162162

163+
/// Build shell export statements from a HashMap of env vars.
164+
/// POSIX: `export KEY='value'; ` with single-quote escaping.
165+
/// Windows: `set "KEY=value" && ` with cmd.exe escaping.
166+
fn build_export_prefix(env_vars: &HashMap<String, String>) -> String {
167+
let safe_env: Vec<_> = env_vars
168+
.iter()
169+
.filter(|(k, _)| is_valid_env_key(k))
170+
.collect();
171+
172+
if safe_env.is_empty() {
173+
return String::new();
174+
}
175+
176+
if cfg!(windows) {
177+
let parts: Vec<_> = safe_env
178+
.iter()
179+
.map(|(k, v)| {
180+
let escaped = v
181+
.replace('^', "^^")
182+
.replace('%', "%%")
183+
.replace('"', "\\\"")
184+
.replace('&', "^&")
185+
.replace('|', "^|")
186+
.replace('<', "^<")
187+
.replace('>', "^>")
188+
.replace('(', "^(")
189+
.replace(')', "^)");
190+
format!("set \"{}={}\"", k, escaped)
191+
})
192+
.collect();
193+
format!("{} && ", parts.join(" && "))
194+
} else {
195+
let parts: Vec<_> = safe_env
196+
.iter()
197+
.map(|(k, v)| format!("export {}='{}'; ", k, v.replace('\'', "'\\''")))
198+
.collect();
199+
parts.join("")
200+
}
201+
}
202+
203+
/// Build environment variables for terminal hooks.
204+
/// Includes base project vars and, for worktree projects, OKENA_BRANCH.
205+
pub fn terminal_hook_env(
206+
project_id: &str,
207+
project_name: &str,
208+
project_path: &str,
209+
is_worktree: bool,
210+
) -> HashMap<String, String> {
211+
let mut env = project_env(project_id, project_name, project_path);
212+
if is_worktree {
213+
let path = std::path::Path::new(project_path);
214+
let branch = okena_git::get_git_status(path)
215+
.and_then(|s| s.branch)
216+
.or_else(|| okena_git::get_current_branch(path));
217+
if let Some(branch) = branch {
218+
env.insert("OKENA_BRANCH".into(), branch);
219+
}
220+
}
221+
env
222+
}
223+
163224
/// Build a `std::process::Command` for headless hook execution.
164225
/// Handles platform dispatch (sh -c / cmd /C), env vars, and cwd.
165226
fn build_headless_command(command: &str, env_vars: &HashMap<String, String>) -> std::process::Command {
@@ -558,12 +619,14 @@ pub fn fire_on_worktree_close(
558619
project_id: &str,
559620
project_name: &str,
560621
project_path: &str,
622+
branch: &str,
561623
global_hooks: &HooksConfig,
562624
cx: &App,
563625
) {
564626
if let Some(cmd) = resolve_hook(project_hooks, global_hooks, |h| &h.worktree.on_close) {
565-
let env = project_env(project_id, project_name, project_path);
566-
log::info!("Running on_worktree_close hook for project '{}'", project_name);
627+
let mut env = project_env(project_id, project_name, project_path);
628+
env.insert("OKENA_BRANCH".into(), branch.into());
629+
log::info!("Running on_worktree_close hook for project '{}' (branch: {})", project_name, branch);
567630
let monitor = try_monitor(cx);
568631
run_hook(cmd, env, monitor.as_ref(), "on_worktree_close", project_name, None, project_id, true);
569632
}
@@ -777,10 +840,12 @@ pub fn resolve_terminal_on_create_simple(
777840

778841
/// Apply the `terminal.on_create` command by wrapping the shell to run
779842
/// the command first, then `exec` into the original shell.
780-
/// Produces: `sh -c '<on_create_cmd>; exec <shell_cmd>'`
781-
pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str) -> ShellType {
843+
/// Environment variables are exported so they persist in the shell session.
844+
/// Produces: `sh -c 'export K=V; ...; <on_create_cmd>; exec <shell_cmd>'`
845+
pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str, env_vars: &HashMap<String, String>) -> ShellType {
782846
let shell_cmd = shell.to_command_string();
783-
let script = format!("{}; exec {}", on_create_cmd, shell_cmd);
847+
let prefix = build_export_prefix(env_vars);
848+
let script = format!("{}{}; exec {}", prefix, on_create_cmd, shell_cmd);
784849
ShellType::for_command(script)
785850
}
786851

@@ -793,16 +858,30 @@ pub fn fire_terminal_on_close(
793858
project_name: &str,
794859
project_path: &str,
795860
terminal_id: &str,
861+
terminal_name: Option<&str>,
862+
is_worktree: bool,
796863
exit_code: Option<u32>,
797864
global_hooks: &HooksConfig,
798865
cx: &App,
799866
) {
800867
if let Some(cmd) = resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.on_close) {
801868
let mut env = project_env(project_id, project_name, project_path);
802869
env.insert("OKENA_TERMINAL_ID".into(), terminal_id.into());
870+
if let Some(name) = terminal_name {
871+
env.insert("OKENA_TERMINAL_NAME".into(), name.into());
872+
}
803873
if let Some(code) = exit_code {
804874
env.insert("OKENA_EXIT_CODE".into(), code.to_string());
805875
}
876+
if is_worktree {
877+
let path = std::path::Path::new(project_path);
878+
let branch = okena_git::get_git_status(path)
879+
.and_then(|s| s.branch)
880+
.or_else(|| okena_git::get_current_branch(path));
881+
if let Some(branch) = branch {
882+
env.insert("OKENA_BRANCH".into(), branch);
883+
}
884+
}
806885
log::info!("Running terminal.on_close hook for terminal '{}'", terminal_id);
807886
let monitor = try_monitor(cx);
808887
run_hook(cmd, env, monitor.as_ref(), "terminal.on_close", project_name, None, project_id, true);
@@ -821,20 +900,22 @@ pub fn resolve_shell_wrapper(
821900

822901
/// Apply shell_wrapper to a ShellType, producing a new ShellType.
823902
/// The wrapper template uses `{shell}` as a placeholder for the resolved shell command.
903+
/// Environment variables are exported so they persist in the shell session.
824904
///
825905
/// If the result contains shell metacharacters (`&&`, `||`, `;`, `|`), it is wrapped
826906
/// in `sh -c` for proper execution. Otherwise, it is split into executable + args directly,
827907
/// avoiding an extra `sh` process layer (important for session backends like dtach/tmux).
828908
///
829909
/// The shell is expected to be already resolved (not `ShellType::Default`).
830-
pub fn apply_shell_wrapper(shell: &ShellType, wrapper: &str) -> ShellType {
910+
pub fn apply_shell_wrapper(shell: &ShellType, wrapper: &str, env_vars: &HashMap<String, String>) -> ShellType {
831911
let shell_cmd = shell.to_command_string();
832912
// Replace {shell} with `exec <shell>` so the shell replaces the wrapper process.
833913
// This is critical for session backends (dtach/tmux) that monitor the top-level process.
834914
let wrapped = wrapper.replace("{shell}", &format!("exec {}", shell_cmd));
915+
let prefix = build_export_prefix(env_vars);
835916
// Always use for_command (sh -c '...') so that build_terminal_command can extract
836917
// the inner command for session backend integration (dtach/tmux/screen).
837-
ShellType::for_command(wrapped)
918+
ShellType::for_command(format!("{}{}", prefix, wrapped))
838919
}
839920

840921
#[cfg(test)]
@@ -1003,7 +1084,7 @@ mod tests {
10031084
args: vec!["--login".to_string()],
10041085
};
10051086
let wrapper = "devcontainer exec -- {shell}";
1006-
let wrapped = apply_shell_wrapper(&shell, wrapper);
1087+
let wrapped = apply_shell_wrapper(&shell, wrapper, &HashMap::new());
10071088
match &wrapped {
10081089
ShellType::Custom { path: _, args } => {
10091090
// for_command uses $SHELL -ic on Unix
@@ -1022,7 +1103,7 @@ mod tests {
10221103
args: vec![],
10231104
};
10241105
let wrapper = "echo hello && {shell}";
1025-
let wrapped = apply_shell_wrapper(&shell, wrapper);
1106+
let wrapped = apply_shell_wrapper(&shell, wrapper, &HashMap::new());
10261107
match &wrapped {
10271108
ShellType::Custom { path: _, args } => {
10281109
// for_command uses $SHELL -ic on Unix
@@ -1041,4 +1122,85 @@ mod tests {
10411122
};
10421123
assert_eq!(shell.to_command_string(), "/usr/bin/fish");
10431124
}
1125+
1126+
#[test]
1127+
fn build_export_prefix_empty() {
1128+
assert_eq!(build_export_prefix(&HashMap::new()), "");
1129+
}
1130+
1131+
#[test]
1132+
fn build_export_prefix_single_var() {
1133+
let mut env = HashMap::new();
1134+
env.insert("MY_VAR".into(), "hello".into());
1135+
let prefix = build_export_prefix(&env);
1136+
assert!(prefix.contains("MY_VAR"), "got: {}", prefix);
1137+
assert!(prefix.contains("hello"), "got: {}", prefix);
1138+
if cfg!(windows) {
1139+
assert!(prefix.contains("set"), "got: {}", prefix);
1140+
} else {
1141+
assert!(prefix.contains("export"), "got: {}", prefix);
1142+
}
1143+
}
1144+
1145+
#[test]
1146+
fn build_export_prefix_escapes_single_quotes() {
1147+
let mut env = HashMap::new();
1148+
env.insert("VAR".into(), "it's a test".into());
1149+
let prefix = build_export_prefix(&env);
1150+
if !cfg!(windows) {
1151+
// POSIX: single quotes with '\'' escaping
1152+
assert!(prefix.contains("'\\''"), "Expected single-quote escape in: {}", prefix);
1153+
}
1154+
}
1155+
1156+
#[test]
1157+
fn build_export_prefix_filters_invalid_keys() {
1158+
let mut env = HashMap::new();
1159+
env.insert("GOOD_KEY".into(), "val".into());
1160+
env.insert("BAD;KEY".into(), "val".into());
1161+
env.insert("123BAD".into(), "val".into());
1162+
let prefix = build_export_prefix(&env);
1163+
assert!(prefix.contains("GOOD_KEY"), "got: {}", prefix);
1164+
assert!(!prefix.contains("BAD;KEY"), "got: {}", prefix);
1165+
assert!(!prefix.contains("123BAD"), "got: {}", prefix);
1166+
}
1167+
1168+
#[test]
1169+
fn apply_on_create_with_env_vars() {
1170+
let shell = ShellType::Custom {
1171+
path: "/bin/bash".to_string(),
1172+
args: vec![],
1173+
};
1174+
let mut env = HashMap::new();
1175+
env.insert("OKENA_PROJECT_ID".into(), "proj-123".into());
1176+
let result = apply_on_create(&shell, "echo hello", &env);
1177+
match &result {
1178+
ShellType::Custom { path: _, args } => {
1179+
let cmd = &args[1];
1180+
assert!(cmd.contains("export OKENA_PROJECT_ID="), "got: {}", cmd);
1181+
assert!(cmd.contains("echo hello"), "got: {}", cmd);
1182+
assert!(cmd.contains("exec /bin/bash"), "got: {}", cmd);
1183+
}
1184+
other => panic!("Expected ShellType::Custom, got: {:?}", other),
1185+
}
1186+
}
1187+
1188+
#[test]
1189+
fn apply_shell_wrapper_with_env_vars() {
1190+
let shell = ShellType::Custom {
1191+
path: "/bin/zsh".to_string(),
1192+
args: vec![],
1193+
};
1194+
let mut env = HashMap::new();
1195+
env.insert("OKENA_PROJECT_NAME".into(), "my-project".into());
1196+
let result = apply_shell_wrapper(&shell, "wrapper {shell}", &env);
1197+
match &result {
1198+
ShellType::Custom { path: _, args } => {
1199+
let cmd = &args[1];
1200+
assert!(cmd.contains("export OKENA_PROJECT_NAME="), "got: {}", cmd);
1201+
assert!(cmd.contains("wrapper exec /bin/zsh"), "got: {}", cmd);
1202+
}
1203+
other => panic!("Expected ShellType::Custom, got: {:?}", other),
1204+
}
1205+
}
10441206
}

docs/hooks.md

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ Available on worktree and merge hooks.
9191
| `OKENA_TARGET_BRANCH` | Target branch for merge operations (`pre_merge`, `post_merge`, `on_rebase_conflict`) |
9292
| `OKENA_MAIN_REPO_PATH` | Path to the main repository (merge and worktree-remove hooks) |
9393

94+
### Terminal variables
95+
96+
| Variable | Description |
97+
|----------|-------------|
98+
| `OKENA_TERMINAL_ID` | Unique ID of the terminal (`terminal.on_close` only) |
99+
| `OKENA_TERMINAL_NAME` | Custom name of the terminal, if set (`terminal.on_close` only) |
100+
| `OKENA_EXIT_CODE` | Exit code of the terminal process (`terminal.on_close` only) |
101+
94102
### Conflict variables
95103

96104
| Variable | Description |
@@ -99,18 +107,23 @@ Available on worktree and merge hooks.
99107

100108
### Variable availability by hook
101109

102-
| Hook | `BRANCH` | `TARGET_BRANCH` | `MAIN_REPO_PATH` | `REBASE_ERROR` |
103-
|------|----------|-----------------|-------------------|----------------|
104-
| `on_project_open` | | | | |
105-
| `on_project_close` | | | | |
106-
| `on_worktree_create` | yes | | | |
107-
| `on_worktree_close` | | | | |
108-
| `pre_merge` | yes | yes | yes | |
109-
| `post_merge` | yes | yes | yes | |
110-
| `before_worktree_remove` | yes | | yes | |
111-
| `worktree_removed` | yes | | yes | |
112-
| `on_rebase_conflict` | yes | yes | yes | yes |
113-
| `on_dirty_worktree_close` | yes | | | |
110+
| Hook | `PROJECT_*` | `BRANCH` | `TARGET_BRANCH` | `MAIN_REPO_PATH` | `REBASE_ERROR` | `TERMINAL_ID` | `TERMINAL_NAME` | `EXIT_CODE` |
111+
|------|-------------|----------|-----------------|-------------------|----------------|---------------|-----------------|-------------|
112+
| `on_project_open` | yes | | | | | | | |
113+
| `on_project_close` | yes | | | | | | | |
114+
| `on_worktree_create` | yes | yes | | | | | | |
115+
| `on_worktree_close` | yes | yes | | | | | | |
116+
| `pre_merge` | yes | yes | yes | yes | | | | |
117+
| `post_merge` | yes | yes | yes | yes | | | | |
118+
| `before_worktree_remove` | yes | yes | | yes | | | | |
119+
| `worktree_removed` | yes | yes | | yes | | | | |
120+
| `on_rebase_conflict` | yes | yes | yes | yes | yes | | | |
121+
| `on_dirty_worktree_close` | yes | yes | | | | | | |
122+
| `terminal.on_create` | yes | worktree | | | | | | |
123+
| `terminal.shell_wrapper` | yes | worktree | | | | | | |
124+
| `terminal.on_close` | yes | worktree | | | | yes | if set | yes |
125+
126+
For `terminal.on_create` and `terminal.shell_wrapper`, environment variables are exported into the shell session so they persist after the hook command runs. For worktree projects, `OKENA_BRANCH` is included automatically.
114127

115128
## Hook Monitor
116129

0 commit comments

Comments
 (0)