From d95910967c0989019b222b6a2b6ffeee9be908d4 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Sun, 3 May 2026 17:32:43 +0200 Subject: [PATCH] Add ghostel-pre-spawn-hook for env injection at spawn time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provide a hook that fires inside `ghostel--spawn-pty' immediately before `make-process', with `process-environment' dynamically bound to the env that will be passed to the child. Hook functions can `setenv' to inject or override entries that the spawned process inherits at fork time. One firing site covers both spawn paths (`ghostel--start-process' used by `ghostel'/`ghostel-project', and `ghostel-exec'). `ghostel-compile' has its own `make-process' call and is not covered, matching today's separation. Motivation: third-party packages like with-editor need to set `EDITOR' so Magit's commit-edit / rebase-edit flows pop their files in the running Emacs instead of trying to launch a new editor. The value isn't known statically (it depends on the live Emacs server), so `ghostel-environment' can't carry it; and a static mode-hook fires too early — before the PTY exists. Pre-spawn env injection (this hook) is preferable to post-spawn wire injection (sending `export EDITOR=...\\n' to a live shell) because env inheritance avoids: the shell-rc race, the leading- space history-suppress dance, visible buffer noise, and the footgun where the export bytes would land as keystrokes when `ghostel-exec' is used to launch a TUI like htop or claude. Test drives a real `/bin/sh' through `ghostel--start-process', has the hook `setenv' a sentinel, and asserts the value reached `make-process'. --- CHANGELOG.md | 13 +++++++++++ lisp/ghostel.el | 55 +++++++++++++++++++++++++++++--------------- test/ghostel-test.el | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb4c888..28eb1f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- `ghostel-pre-spawn-hook`, run inside `ghostel--spawn-pty` just + before `make-process` with `process-environment` dynamically + bound to the about-to-be-spawned env. Hook functions can + `setenv` to inject entries the child inherits. Intended for + integrations like with-editor — with a `with-editor-setup-environment` + exposed upstream, users can wire Magit's `EDITOR` plumbing into + ghostel buffers via + `(add-hook 'ghostel-pre-spawn-hook + #'with-editor-setup-environment)`. Fires for both + `ghostel`/`ghostel-project` and `ghostel-exec` spawns; + `ghostel-compile` has its own `make-process` and is not covered. + ### Fixed - Launching `M-x ghostel` from a TRAMP `default-directory` (e.g. after `find-file /ssh:host:`) now produces a usable remote shell. diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 0c3a627a..3b562cc0 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -378,6 +378,18 @@ Errors in hook functions are demoted to messages via non-nil so the debugger can fire)." :type 'hook) +(defcustom ghostel-pre-spawn-hook nil + "Hook run inside `ghostel--spawn-pty' just before `make-process'. +Each function is called with no arguments in the buffer that will +host the new process. `process-environment' is dynamically bound +to the env that will be passed to the child, so hook functions can +inject or override entries with `setenv' and the spawned process +inherits them. + +Use this hook for one-time pre-spawn setup; see `ghostel-environment' +for static env entries that don't depend on runtime state." + :type 'hook) + (defcustom ghostel-eval-cmds '(("find-file" find-file) ("find-file-other-window" find-file-other-window) ("dired" dired) @@ -3534,25 +3546,30 @@ matches the PTY window size, and stores the process in (tramp-terminal-type (if remote-p (ghostel--remote-tramp-terminal-type extra-env) - tramp-terminal-type)) - (proc (make-process - :name "ghostel" - :buffer (current-buffer) - :command shell-command - :connection-type 'pty - :file-handler remote-p - :filter #'ghostel--filter - :sentinel #'ghostel--sentinel))) - (setq ghostel--process proc) - ;; Raw binary I/O — no encoding/decoding by Emacs - (set-process-coding-system proc 'binary 'binary) - ;; Set the PTY's actual window size (ioctl TIOCSWINSZ) so that - ;; the program's line editor (readline/ZLE) can render properly. - (set-process-window-size proc height width) - (set-process-query-on-exit-flag proc nil) - (process-put proc 'adjust-window-size-function - #'ghostel--window-adjust-process-window-size) - proc)) + tramp-terminal-type))) + ;; Pre-spawn hook: runs while `process-environment' is dynamically + ;; bound to the about-to-be-spawned env, so hook functions can + ;; `setenv' to inject/override entries that the child inherits. + ;; See `ghostel-pre-spawn-hook'. + (run-hooks 'ghostel-pre-spawn-hook) + (let ((proc (make-process + :name "ghostel" + :buffer (current-buffer) + :command shell-command + :connection-type 'pty + :file-handler remote-p + :filter #'ghostel--filter + :sentinel #'ghostel--sentinel))) + (setq ghostel--process proc) + ;; Raw binary I/O — no encoding/decoding by Emacs + (set-process-coding-system proc 'binary 'binary) + ;; Set the PTY's actual window size (ioctl TIOCSWINSZ) so that + ;; the program's line editor (readline/ZLE) can render properly. + (set-process-window-size proc height width) + (set-process-query-on-exit-flag proc nil) + (process-put proc 'adjust-window-size-function + #'ghostel--window-adjust-process-window-size) + proc))) (defun ghostel--start-process () "Start the shell process with a PTY. diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 4decefce..273c942f 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -8315,6 +8315,47 @@ buffer eventually shows up." (should (equal (nth 1 captured) 80)))) (kill-buffer buf)))) +(ert-deftest ghostel-test-pre-spawn-hook-injects-into-process-environment () + "Hook `setenv' calls reach the spawned process via `process-environment'. +`ghostel-pre-spawn-hook' fires with `process-environment' dynamically +bound to the about-to-be-spawned env, so hook functions that call +`setenv' inject entries the child process actually inherits. + +Contract relied on by integrations like with-editor: drive a real +`/bin/sh' through `ghostel--start-process', have the hook `setenv' a +sentinel value, and verify the value reached `make-process'. Also +verifies the hook fires in the spawning buffer with `default-directory' +intact (with-editor's `with-editor--setup' reads `default-directory')." + (let ((captured-env nil) + captured-buffer + captured-default-directory + (orig-make-process (symbol-function #'make-process))) + (cl-letf (((symbol-function #'make-process) + (lambda (&rest plist) + (setq captured-env process-environment) + (apply orig-make-process plist)))) + (with-temp-buffer + (setq-local ghostel--term-rows 24 + ghostel--term-cols 80) + (let* ((process-environment '("PATH=/usr/bin:/bin" "HOME=/tmp")) + (ghostel-shell "/bin/sh") + (ghostel-shell-integration nil) + (default-directory "/tmp/") + (test-buffer (current-buffer)) + (ghostel-pre-spawn-hook + (list (lambda () + (setq captured-buffer (current-buffer)) + (setq captured-default-directory default-directory) + (setenv "GHOSTEL_PRE_SPAWN_TEST" "ok")))) + (proc (ghostel--start-process))) + (unwind-protect + (progn + (should (eq captured-buffer test-buffer)) + (should (equal captured-default-directory "/tmp/")) + (should (member "GHOSTEL_PRE_SPAWN_TEST=ok" captured-env))) + (when (process-live-p proc) + (delete-process proc)))))))) + ;; ----------------------------------------------------------------------- ;; Test: ghostel-eshell integration ;; -----------------------------------------------------------------------