aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 22:52:34 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 22:52:34 -0400
commitc99fad28fa57150ae684dc98c4112f50b51b5b27 (patch)
tree071954dfd6121e62343ccabf6287766a4844e393
parentcbd38d881d723f04aab748740977916707f24034 (diff)
downloaddotemacs-c99fad28fa57150ae684dc98c4112f50b51b5b27.tar.gz
dotemacs-c99fad28fa57150ae684dc98c4112f50b51b5b27.zip
feat(eshell): zsh-parity prompt segments and zoxide
Bring eshell closer to the zsh terminal it replaces. The prompt now shows the git branch (read from .git/HEAD, no subprocess, skipped on remote so TRAMP stays fast) and a [N] exit-status segment when the last command failed, matching the zsh prompt's info. Add a zoxide z command and an eshell-directory-change hook that feeds zoxide add, sharing the same frecency database as the zsh shell. New tests cover the pure prompt helpers.
-rw-r--r--modules/eshell-config.el59
-rw-r--r--tests/test-eshell-config--prompt.el75
2 files changed, 134 insertions, 0 deletions
diff --git a/modules/eshell-config.el b/modules/eshell-config.el
index ac583cf70..7379795d2 100644
--- a/modules/eshell-config.el
+++ b/modules/eshell-config.el
@@ -51,6 +51,9 @@
(declare-function eshell-send-input "esh-mode")
(declare-function eshell/pwd "em-dirs")
(declare-function eshell/alias "em-alias")
+(declare-function eshell/cd "em-dirs")
+(declare-function eshell-stringify "esh-util")
+(declare-function eat-eshell-mode "eat")
(defgroup cj/eshell nil
"Personal Eshell configuration."
@@ -83,6 +86,59 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(dolist (pair (cj/--eshell-ssh-alias-commands hosts))
(eshell/alias (car pair) (cdr pair))))
+;; ---------------------------- prompt segments --------------------------------
+
+(defun cj/--eshell-git-branch ()
+ "Return the current git branch for `default-directory', or nil.
+Reads .git/HEAD directly so it adds no subprocess per prompt, and skips remote
+directories so a TRAMP prompt stays fast."
+ (unless (file-remote-p default-directory)
+ (when-let* ((root (locate-dominating-file default-directory ".git"))
+ (head (expand-file-name ".git/HEAD" root)))
+ (when (file-readable-p head)
+ (with-temp-buffer
+ (insert-file-contents head)
+ (when (looking-at "ref: refs/heads/\\(.*\\)")
+ (string-trim (match-string 1))))))))
+
+(defun cj/--eshell-prompt-status-segment ()
+ "Return the eshell prompt's exit-status segment, or an empty string.
+Shows the last command's exit code in brackets when it was non-zero, mirroring
+the zsh prompt's failure indicator."
+ (let ((status (bound-and-true-p eshell-last-command-status)))
+ (if (or (null status) (zerop status))
+ ""
+ (format " [%d]" status))))
+
+;; ------------------------------- zoxide --------------------------------------
+;; Share the same frecency database as the zsh shell by calling the zoxide
+;; binary: `z' jumps to a remembered directory, and every eshell directory
+;; change feeds `zoxide add' so eshell visits accrue in the same database.
+
+(defun eshell/z (&rest args)
+ "Jump to a directory via zoxide, sharing the zsh zoxide database.
+With no ARGS, cd home. Otherwise query zoxide for the best match and cd there."
+ (if (null args)
+ (eshell/cd)
+ (let ((dir (string-trim
+ (shell-command-to-string
+ (concat "zoxide query -- "
+ (mapconcat #'shell-quote-argument
+ (mapcar #'eshell-stringify args) " "))))))
+ (if (and (not (string-empty-p dir)) (file-directory-p dir))
+ (eshell/cd dir)
+ (error "zoxide: no match for %s"
+ (string-join (mapcar #'eshell-stringify args) " "))))))
+
+(defun cj/--eshell-zoxide-add ()
+ "Record `default-directory' in the zoxide database (skips remote dirs)."
+ (when (and (not (file-remote-p default-directory))
+ (executable-find "zoxide"))
+ (call-process "zoxide" nil 0 nil "add" "--"
+ (expand-file-name default-directory))))
+
+(add-hook 'eshell-directory-change-hook #'cj/--eshell-zoxide-add)
+
(use-package eshell
:ensure nil ;; built-in
:commands (eshell)
@@ -108,6 +164,9 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(propertize (system-name) 'face 'default)
":"
(propertize (abbreviate-file-name (eshell/pwd)) 'face 'default)
+ (let ((branch (cj/--eshell-git-branch)))
+ (if branch (propertize (concat " (" branch ")") 'face 'default) ""))
+ (propertize (cj/--eshell-prompt-status-segment) 'face 'default)
"\n"
(propertize "%" 'face 'default)
" ")))
diff --git a/tests/test-eshell-config--prompt.el b/tests/test-eshell-config--prompt.el
new file mode 100644
index 000000000..7073c7e0b
--- /dev/null
+++ b/tests/test-eshell-config--prompt.el
@@ -0,0 +1,75 @@
+;;; test-eshell-config--prompt.el --- Tests for eshell prompt helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure prompt-segment helpers added for zsh parity: the
+;; .git/HEAD branch reader and the exit-status segment.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'eshell-config)
+
+(defvar eshell-last-command-status) ; declared special for the status tests
+
+;;; cj/--eshell-git-branch
+
+(ert-deftest test-eshell-git-branch-reads-head ()
+ "Normal: a .git/HEAD pointing at a branch returns the branch name."
+ (let ((dir (make-temp-file "esh-git-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name ".git" dir))
+ (with-temp-file (expand-file-name ".git/HEAD" dir)
+ (insert "ref: refs/heads/feature-x\n"))
+ (let ((default-directory (file-name-as-directory dir)))
+ (should (equal (cj/--eshell-git-branch) "feature-x"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-no-repo-nil ()
+ "Boundary: a directory with no .git returns nil."
+ (let ((dir (make-temp-file "esh-nogit-" t)))
+ (unwind-protect
+ (let ((default-directory (file-name-as-directory dir)))
+ (should-not (cj/--eshell-git-branch)))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-detached-nil ()
+ "Boundary: a detached HEAD (a raw SHA, no ref) returns nil."
+ (let ((dir (make-temp-file "esh-detached-" t)))
+ (unwind-protect
+ (progn
+ (make-directory (expand-file-name ".git" dir))
+ (with-temp-file (expand-file-name ".git/HEAD" dir)
+ (insert "a1b2c3d4e5f6\n"))
+ (let ((default-directory (file-name-as-directory dir)))
+ (should-not (cj/--eshell-git-branch))))
+ (delete-directory dir t))))
+
+(ert-deftest test-eshell-git-branch-remote-skipped ()
+ "Boundary: a remote default-directory is skipped (no TRAMP read)."
+ (let ((default-directory "/ssh:host:/some/path/"))
+ (should-not (cj/--eshell-git-branch))))
+
+;;; cj/--eshell-prompt-status-segment
+
+(ert-deftest test-eshell-prompt-status-zero-empty ()
+ "Normal: a zero exit status yields an empty segment."
+ (let ((eshell-last-command-status 0))
+ (should (equal (cj/--eshell-prompt-status-segment) ""))))
+
+(ert-deftest test-eshell-prompt-status-nonzero-bracketed ()
+ "Normal: a non-zero exit status is shown in brackets."
+ (let ((eshell-last-command-status 1))
+ (should (equal (cj/--eshell-prompt-status-segment) " [1]")))
+ (let ((eshell-last-command-status 130))
+ (should (equal (cj/--eshell-prompt-status-segment) " [130]"))))
+
+(ert-deftest test-eshell-prompt-status-unset-empty ()
+ "Boundary: an unset status yields an empty segment, no error."
+ (let ((eshell-last-command-status nil))
+ (should (equal (cj/--eshell-prompt-status-segment) ""))))
+
+(provide 'test-eshell-config--prompt)
+;;; test-eshell-config--prompt.el ends here