summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-config.el22
-rw-r--r--tests/test-ai-config-gptel-prompt-tab-width.el106
-rw-r--r--tests/testutil-ai-config.el15
3 files changed, 143 insertions, 0 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index 4a3d6a82..2783bc18 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -379,6 +379,28 @@ Works for any buffer, whether it's visiting a file or not."
(advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix)
(add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading))
+;; Workaround: gptel's `gptel--with-buffer-copy-internal' copies the
+;; source buffer's `major-mode' symbol into the prompt buffer but does
+;; not run mode hooks, so `org-mode-hook' never fires there. In this
+;; config the global `tab-width' default is 4, while `org-mode-hook'
+;; sets it to 8 — so an inherited-org-mode prompt buffer keeps
+;; `tab-width=4', and Org's `org-element--list-struct' guard raises
+;; "Tab width in Org files must be 8" when gptel later parses it.
+;;
+;; Triggered in practice by `gptel-magit-generate-message' run from
+;; COMMIT_EDITMSG with `git-commit-major-mode' set to `org-mode' (see
+;; modules/vc-config.el). Force `tab-width=8' before `body-thunk'
+;; runs so the prompt buffer satisfies Org's invariant.
+(define-advice gptel--with-buffer-copy-internal
+ (:around (orig buf start end body-thunk) cj/fix-org-tab-width)
+ "Force `tab-width=8' in the gptel prompt buffer when its inherited
+`major-mode' is `org-mode'."
+ (funcall orig buf start end
+ (lambda ()
+ (when (eq major-mode 'org-mode)
+ (setq-local tab-width 8))
+ (funcall body-thunk))))
+
;;; ---------------------------- Toggle GPTel Window ----------------------------
(defun cj/toggle-gptel ()
diff --git a/tests/test-ai-config-gptel-prompt-tab-width.el b/tests/test-ai-config-gptel-prompt-tab-width.el
new file mode 100644
index 00000000..6f857cdc
--- /dev/null
+++ b/tests/test-ai-config-gptel-prompt-tab-width.el
@@ -0,0 +1,106 @@
+;;; test-ai-config-gptel-prompt-tab-width.el --- Tests for gptel prompt-buffer tab-width fix -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Regression tests for the `org-element--list-struct: Tab width in Org
+;; files must be 8, not 4' error that fires when `gptel-magit' is
+;; invoked from a `COMMIT_EDITMSG' buffer where `git-commit-major-mode'
+;; is `org-mode'.
+;;
+;; Root cause: gptel's `gptel--with-buffer-copy-internal'
+;; (gptel-request.el:945) sets the prompt buffer's `major-mode' to the
+;; source buffer's mode as a *symbol*, without running mode hooks. So
+;; an inherited `org-mode' prompt buffer keeps the global default
+;; `tab-width' (4 in this config) — `org-mode-hook' never runs to set
+;; 8. When gptel later parses the prompt buffer with `org-element',
+;; Org's `tab-width=8' guard raises.
+;;
+;; Fix: an `:around' advice on `gptel--with-buffer-copy-internal' in
+;; ai-config.el sets `tab-width=8' inside the prompt buffer when its
+;; inherited `major-mode' is `org-mode'.
+;;
+;; These tests verify the advice fires for org-mode source buffers and
+;; stays out of the way for other modes. Each test pins
+;; `default-value' of `tab-width' to 4 — matching Craig's real config
+;; — so the advice's effect is observable in batch tests (where the
+;; default would otherwise be Emacs's built-in 8).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; testutil-ai-config provides the gptel--with-buffer-copy-internal stub
+;; and the gptel stubs ai-config needs to load.
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+(defmacro test-ai-config--with-default-tab-width (sentinel &rest body)
+ "Bind `default-value' of `tab-width' to SENTINEL during BODY.
+Restores the prior default on unwind. New buffers created inside BODY
+inherit SENTINEL as their `tab-width'."
+ (declare (indent 1))
+ `(let ((saved (default-value 'tab-width)))
+ (unwind-protect
+ (progn (setq-default tab-width ,sentinel)
+ ,@body)
+ (setq-default tab-width saved))))
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-gptel-prompt-tab-width-org-mode-source-sets-8 ()
+ "Normal: source buffer in `org-mode' → prompt buffer has `tab-width=8'.
+Regression net for the `org-element--list-struct: Tab width must be 8'
+error when gptel-magit runs from COMMIT_EDITMSG with
+`git-commit-major-mode=org-mode'."
+ (test-ai-config--with-default-tab-width 4
+ (let ((recorded nil))
+ (with-temp-buffer
+ (setq major-mode 'org-mode)
+ (gptel--with-buffer-copy-internal
+ (current-buffer) nil nil
+ (lambda () (setq recorded tab-width))))
+ (should (= recorded 8)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-gptel-prompt-tab-width-text-mode-source-not-overridden ()
+ "Boundary: source buffer in `text-mode' → advice does NOT set `tab-width=8'.
+Only `org-mode' source triggers the override; other modes keep the
+inherited default."
+ (test-ai-config--with-default-tab-width 4
+ (let ((recorded nil))
+ (with-temp-buffer
+ (setq major-mode 'text-mode)
+ (gptel--with-buffer-copy-internal
+ (current-buffer) nil nil
+ (lambda () (setq recorded tab-width))))
+ (should (= recorded 4)))))
+
+(ert-deftest test-ai-config-gptel-prompt-tab-width-fundamental-mode-source-not-overridden ()
+ "Boundary: source buffer in `fundamental-mode' → no override.
+Confirms the advice's `(eq major-mode 'org-mode)' gate is strict."
+ (test-ai-config--with-default-tab-width 4
+ (let ((recorded nil))
+ (with-temp-buffer
+ (setq major-mode 'fundamental-mode)
+ (gptel--with-buffer-copy-internal
+ (current-buffer) nil nil
+ (lambda () (setq recorded tab-width))))
+ (should (= recorded 4)))))
+
+(ert-deftest test-ai-config-gptel-prompt-tab-width-advice-does-not-leak ()
+ "Boundary: advice's `setq-local' stays buffer-local to the prompt buffer.
+After body-thunk completes and the prompt buffer is killed, the source
+buffer's `tab-width' is unchanged."
+ (with-temp-buffer
+ (setq major-mode 'org-mode)
+ (setq-local tab-width 4)
+ (gptel--with-buffer-copy-internal
+ (current-buffer) nil nil
+ (lambda () nil))
+ (should (= tab-width 4))))
+
+(provide 'test-ai-config-gptel-prompt-tab-width)
+;;; test-ai-config-gptel-prompt-tab-width.el ends here
diff --git a/tests/testutil-ai-config.el b/tests/testutil-ai-config.el
index c7486222..f56a38e1 100644
--- a/tests/testutil-ai-config.el
+++ b/tests/testutil-ai-config.el
@@ -74,6 +74,21 @@
;; so the magit integration only activates when magit is provided.
;; See test-ai-config-gptel-magit-lazy-loading.el for magit stub tests.
+;; Stub `gptel--with-buffer-copy-internal' so the advice attached to it
+;; in ai-config.el can be exercised without loading real gptel-request.
+;; Mirrors the salient bits of the real function (gptel-request.el:945):
+;; create a temp buffer, copy major-mode as a symbol (no hooks), call
+;; body-thunk inside. See test-ai-config-gptel-prompt-tab-width.el.
+(unless (fboundp 'gptel--with-buffer-copy-internal)
+ (defun gptel--with-buffer-copy-internal (buf _start _end body-thunk)
+ "Stub: create temp buffer, copy major-mode from BUF, run BODY-THUNK."
+ (let ((temp (generate-new-buffer " *gptel-prompt-stub*" t)))
+ (unwind-protect
+ (with-current-buffer temp
+ (setq major-mode (buffer-local-value 'major-mode buf))
+ (funcall body-thunk))
+ (kill-buffer temp)))))
+
;; Stub ai-conversations
(provide 'ai-conversations)