@@ -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.
165226fn 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}
0 commit comments