diff options
| -rw-r--r-- | gptel-tools/git_diff.el | 104 | ||||
| -rw-r--r-- | gptel-tools/git_log.el | 94 | ||||
| -rw-r--r-- | gptel-tools/git_status.el | 79 | ||||
| -rw-r--r-- | modules/ai-config.el | 5 | ||||
| -rw-r--r-- | tests/test-gptel-tools-git-diff.el | 137 | ||||
| -rw-r--r-- | tests/test-gptel-tools-git-log.el | 135 | ||||
| -rw-r--r-- | tests/test-gptel-tools-git-status.el | 98 |
7 files changed, 651 insertions, 1 deletions
diff --git a/gptel-tools/git_diff.el b/gptel-tools/git_diff.el new file mode 100644 index 00000000..daccdc20 --- /dev/null +++ b/gptel-tools/git_diff.el @@ -0,0 +1,104 @@ +;;; git_diff.el --- Read-only git diff tool for gptel -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Keywords: convenience, tools, git + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Gptel tool returning `git diff' output for a path under the user's +;; home directory. Read-only. Output is capped at ~500KB so a +;; runaway diff can't blow up the model's context budget; truncation +;; is reported in the output when it triggers. + +;;; Code: + +(require 'gptel) + +(defconst cj/gptel-git-diff--max-output-bytes (* 500 1024) + "Cap on diff output size. Larger diffs are truncated with a note.") + +(defun cj/gptel-git-diff--validate-path (path) + "Validate PATH for a git diff call. Return the expanded path on success. +Same contract as the other git_* validators: under HOME, a directory, +inside a git working tree." + (let ((full (expand-file-name (or path "~") "~"))) + (unless (string-prefix-p (expand-file-name "~") full) + (error "Path must be within home directory: %s" path)) + (unless (file-directory-p full) + (error "Not a directory: %s" full)) + (let ((default-directory full)) + (unless (zerop (process-file "git" nil nil nil + "rev-parse" "--is-inside-work-tree")) + (error "Not a git working tree: %s" full))) + full)) + +(defun cj/gptel-git-diff--truncate (text) + "Truncate TEXT to `cj/gptel-git-diff--max-output-bytes' bytes. +Returns TEXT unchanged when it's under the cap, otherwise returns the +prefix plus a one-line truncation marker." + (if (<= (length text) cj/gptel-git-diff--max-output-bytes) + text + (concat (substring text 0 cj/gptel-git-diff--max-output-bytes) + (format + "\n\n[truncated: output exceeded %d bytes; %d bytes total]" + cj/gptel-git-diff--max-output-bytes + (length text))))) + +(defun cj/gptel-git-diff--build-args (ref1 ref2 file) + "Build the `git' argv from optional REF1, REF2, FILE. +Uses `-c color.ui=false' at the git level so output is plain across +git subcommands." + (let ((args (list "-c" "color.ui=false" "diff"))) + (when (and (stringp ref1) (not (string-empty-p ref1))) + (setq args (append args (list ref1)))) + (when (and (stringp ref2) (not (string-empty-p ref2))) + (setq args (append args (list ref2)))) + (when (and (stringp file) (not (string-empty-p file))) + (setq args (append args (list "--" file)))) + args)) + +(defun cj/gptel-git-diff--run (path &optional ref1 ref2 file) + "Run `git diff [REF1 [REF2]] [-- FILE]' in PATH. Return the output." + (let* ((dir (cj/gptel-git-diff--validate-path path)) + (args (cj/gptel-git-diff--build-args ref1 ref2 file)) + (default-directory dir)) + (with-temp-buffer + (let ((exit (apply #'process-file "git" nil t nil args))) + (unless (or (zerop exit) (= exit 1)) + (error "git diff exited with %d: %s" exit (buffer-string))) + (let ((out (buffer-string))) + (if (string-empty-p out) + (format "No diff in %s for the given refs/file" dir) + (cj/gptel-git-diff--truncate out))))))) + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "git_diff" + :function (lambda (path &optional ref1 ref2 file) + (cj/gptel-git-diff--run path ref1 ref2 file)) + :description "Return the output of `git diff' for a directory in the user's home tree. Read-only. REF1 and REF2 are optional git revisions (commit SHA, branch, tag, or expressions like HEAD~3); when both are present the diff is between them, when only REF1 is present the diff is between REF1 and the working tree, when neither is present the diff is unstaged-vs-HEAD. FILE optionally narrows the diff to one path. Output is capped at ~500KB." + :args (list '(:name "path" + :type string + :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').") + '(:name "ref1" + :type string + :description "Optional first git revision (commit, branch, tag, or expression like HEAD~3)." + :optional t) + '(:name "ref2" + :type string + :description "Optional second git revision; pair with REF1 to diff between two refs." + :optional t) + '(:name "file" + :type string + :description "Optional path inside the working tree to narrow the diff to." + :optional t)) + :category "git" + :confirm nil + :include t) + + (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_diff")))) + +(provide 'git_diff) +;;; git_diff.el ends here diff --git a/gptel-tools/git_log.el b/gptel-tools/git_log.el new file mode 100644 index 00000000..9cfae263 --- /dev/null +++ b/gptel-tools/git_log.el @@ -0,0 +1,94 @@ +;;; git_log.el --- Read-only git log tool for gptel -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Keywords: convenience, tools, git + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Gptel tool returning `git log --oneline -n N' for a path under the +;; user's home directory. Read-only. N is capped to keep the model's +;; context budget predictable. + +;;; Code: + +(require 'gptel) + +(defconst cj/gptel-git-log--max-count 100 + "Hard cap on the number of commits `git_log' will return.") + +(defconst cj/gptel-git-log--default-count 20 + "Default commit count when the caller doesn't specify one.") + +(defun cj/gptel-git-log--validate-path (path) + "Validate PATH for a git log call. Return the expanded path on success. +Same contract as the git_status validator: must be under HOME, must +be a directory, must be inside a git working tree." + (let ((full (expand-file-name (or path "~") "~"))) + (unless (string-prefix-p (expand-file-name "~") full) + (error "Path must be within home directory: %s" path)) + (unless (file-directory-p full) + (error "Not a directory: %s" full)) + (let ((default-directory full)) + (unless (zerop (process-file "git" nil nil nil + "rev-parse" "--is-inside-work-tree")) + (error "Not a git working tree: %s" full))) + full)) + +(defun cj/gptel-git-log--effective-count (n) + "Return the commit count to use given caller-supplied N. +Nil / non-integer N → `cj/gptel-git-log--default-count'. +Values above `cj/gptel-git-log--max-count' get capped." + (cond + ((not (integerp n)) cj/gptel-git-log--default-count) + ((< n 1) cj/gptel-git-log--default-count) + ((> n cj/gptel-git-log--max-count) cj/gptel-git-log--max-count) + (t n))) + +(defun cj/gptel-git-log--run (path &optional n since) + "Run `git log --oneline -n N' in PATH. Return the output as a string. +SINCE, if a non-empty string, is passed as `--since=SINCE'." + (let* ((dir (cj/gptel-git-log--validate-path path)) + (count (cj/gptel-git-log--effective-count n)) + (args (list "-c" "color.ui=false" + "log" "--oneline" + (format "-n%d" count))) + (args (if (and (stringp since) (not (string-empty-p since))) + (append args (list (format "--since=%s" since))) + args)) + (default-directory dir)) + (with-temp-buffer + (let ((exit (apply #'process-file "git" nil t nil args))) + (unless (zerop exit) + (error "git log exited with %d: %s" exit (buffer-string))) + (let ((out (buffer-string))) + (if (string-empty-p out) + (format "No commits in %s matching the filter" dir) + out)))))) + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "git_log" + :function (lambda (path &optional n since) + (cj/gptel-git-log--run path n since)) + :description "Return the output of `git log --oneline -n N' for a directory in the user's home tree. Read-only. N defaults to 20 and is capped at 100. Use SINCE to filter commits more recent than a date expression git understands (e.g. '2 weeks ago', '2026-05-01')." + :args (list '(:name "path" + :type string + :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').") + '(:name "n" + :type integer + :description "Number of commits to return. Defaults to 20; capped at 100." + :optional t) + '(:name "since" + :type string + :description "Optional date expression for `git log --since='; e.g. '2 weeks ago' or '2026-05-01'." + :optional t)) + :category "git" + :confirm nil + :include t) + + (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_log")))) + +(provide 'git_log) +;;; git_log.el ends here diff --git a/gptel-tools/git_status.el b/gptel-tools/git_status.el new file mode 100644 index 00000000..300d5da5 --- /dev/null +++ b/gptel-tools/git_status.el @@ -0,0 +1,79 @@ +;;; git_status.el --- Read-only git status tool for gptel -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Keywords: convenience, tools, git + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Gptel tool returning `git status --short --branch' for a path under +;; the user's home directory. Read-only: never writes to the repo, +;; never runs anything that could mutate state. Path validation +;; rejects anything outside HOME and any path that doesn't resolve to +;; a directory inside a git working tree. + +;;; Code: + +(require 'gptel) +(require 'cl-lib) + +(defun cj/gptel-git-status--validate-path (path) + "Validate PATH as a usable working directory for a git status call. +PATH must resolve under the user's home directory, must be an +existing directory, and must be inside a git working tree. Returns +the expanded path string on success; signals `error' otherwise." + (let ((full (expand-file-name (or path "~") "~"))) + (unless (string-prefix-p (expand-file-name "~") full) + (error "Path must be within home directory: %s" path)) + (unless (file-directory-p full) + (error "Not a directory: %s" full)) + (let ((default-directory full)) + (unless (zerop (process-file "git" nil nil nil + "rev-parse" "--is-inside-work-tree")) + (error "Not a git working tree: %s" full))) + full)) + +(defun cj/gptel-git-status--run (path) + "Run `git status --short --branch' in PATH. Return the output. +Color is disabled via `-c color.ui=false' at the git level (`git status' +itself doesn't accept `--no-color' like `git log' / `git diff' do)." + (let* ((dir (cj/gptel-git-status--validate-path path)) + (default-directory dir)) + (with-temp-buffer + (let ((exit (process-file "git" nil t nil + "-c" "color.ui=false" + "status" "--short" "--branch"))) + (unless (zerop exit) + (error "git status exited with %d: %s" exit (buffer-string))) + ;; `--branch' always prints a `## <branch>' header, so empty + ;; output is unreachable. Detect a clean tree by counting the + ;; non-branch lines: if only the header is present, no files + ;; are modified / staged / untracked. + (let* ((out (buffer-string)) + (non-branch-lines + (cl-count-if + (lambda (l) + (and (not (string-empty-p l)) + (not (string-prefix-p "## " l)))) + (split-string out "\n")))) + (if (zerop non-branch-lines) + (format "Clean working tree in %s\n%s" dir (string-trim out)) + out)))))) + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "git_status" + :function (lambda (path) (cj/gptel-git-status--run path)) + :description "Return the output of `git status --short --branch' for a directory in the user's home tree. Read-only. Useful for seeing which files are modified, staged, or untracked, and how the current branch compares to its upstream." + :args (list '(:name "path" + :type string + :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').")) + :category "git" + :confirm nil + :include t) + + (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_status")))) + +(provide 'git_status) +;;; git_status.el ends here diff --git a/modules/ai-config.el b/modules/ai-config.el index 9ac00bfe..a04a32a0 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -59,7 +59,10 @@ write_text_file update_text_file list_directory_files - move_to_trash) + move_to_trash + git_status + git_log + git_diff) "Feature symbols for optional local GPTel tool modules." :type '(repeat symbol) :group 'cj) diff --git a/tests/test-gptel-tools-git-diff.el b/tests/test-gptel-tools-git-diff.el new file mode 100644 index 00000000..59666a32 --- /dev/null +++ b/tests/test-gptel-tools-git-diff.el @@ -0,0 +1,137 @@ +;;; test-gptel-tools-git-diff.el --- Tests for git_diff gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests run against real temp git repos under HOME via `process-file'. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'git_diff) + +;; ---------- helpers + +(defun test-gptel-tools-git-diff--with-repo (fn) + "Create a temp git repo under HOME with one committed file, call FN." + (let* ((name (format ".test-gptel-tools-git-diff-%s" + (format-time-string "%s%N"))) + (dir (expand-file-name name "~"))) + (unwind-protect + (progn + (make-directory dir) + (let ((default-directory dir)) + (call-process "git" nil nil nil "init" "--quiet") + (call-process "git" nil nil nil "config" "user.email" "test@x") + (call-process "git" nil nil nil "config" "user.name" "Test") + (with-temp-file (expand-file-name "f.txt" dir) + (insert "original\n")) + (call-process "git" nil nil nil "add" "f.txt") + (call-process "git" nil nil nil "commit" "--quiet" "-m" "initial")) + (funcall fn dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- build-args + +(ert-deftest test-gptel-tools-git-diff-build-args-no-refs () + "Normal: no refs / no file → bare diff args." + (should (equal (cj/gptel-git-diff--build-args nil nil nil) + '("-c" "color.ui=false" "diff")))) + +(ert-deftest test-gptel-tools-git-diff-build-args-with-ref1 () + "Normal: REF1 appended." + (should (equal (cj/gptel-git-diff--build-args "HEAD~1" nil nil) + '("-c" "color.ui=false" "diff" "HEAD~1")))) + +(ert-deftest test-gptel-tools-git-diff-build-args-with-both-refs () + "Normal: REF1 and REF2 both appended." + (should (equal (cj/gptel-git-diff--build-args "HEAD~1" "HEAD" nil) + '("-c" "color.ui=false" "diff" "HEAD~1" "HEAD")))) + +(ert-deftest test-gptel-tools-git-diff-build-args-with-file () + "Normal: FILE appended after `--'." + (should (equal (cj/gptel-git-diff--build-args nil nil "foo.txt") + '("-c" "color.ui=false" "diff" "--" "foo.txt")))) + +(ert-deftest test-gptel-tools-git-diff-build-args-boundary-empty-strings () + "Boundary: empty-string REF/FILE values are ignored." + (should (equal (cj/gptel-git-diff--build-args "" "" "") + '("-c" "color.ui=false" "diff")))) + +;; ---------- truncate + +(ert-deftest test-gptel-tools-git-diff-truncate-under-cap () + "Normal: short input returns unchanged." + (should (equal (cj/gptel-git-diff--truncate "small diff") "small diff"))) + +(ert-deftest test-gptel-tools-git-diff-truncate-over-cap () + "Boundary: output exceeding the cap is truncated with a marker." + (let* ((cap cj/gptel-git-diff--max-output-bytes) + (huge (make-string (+ cap 1000) ?x)) + (out (cj/gptel-git-diff--truncate huge))) + (should (string-match-p "\\[truncated:" out)) + (should (> (length huge) (length out))))) + +;; ---------- validate-path + +(ert-deftest test-gptel-tools-git-diff-validate-path-normal () + "Normal: validator accepts a git working tree." + (test-gptel-tools-git-diff--with-repo + (lambda (dir) + (should (equal (cj/gptel-git-diff--validate-path dir) dir))))) + +(ert-deftest test-gptel-tools-git-diff-validate-path-error-outside-home () + "Error: path outside HOME signals." + (should-error (cj/gptel-git-diff--validate-path "/etc"))) + +(ert-deftest test-gptel-tools-git-diff-validate-path-error-not-a-repo () + "Error: non-git directory signals." + (let ((dir (make-temp-file + (expand-file-name ".test-gptel-tools-git-diff-" "~") t))) + (unwind-protect + (should-error (cj/gptel-git-diff--validate-path dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- run + +(ert-deftest test-gptel-tools-git-diff-run-no-changes () + "Boundary: a clean tree with no refs returns the no-diff marker." + (test-gptel-tools-git-diff--with-repo + (lambda (dir) + (let ((out (cj/gptel-git-diff--run dir))) + (should (string-match-p "No diff" out)))))) + +(ert-deftest test-gptel-tools-git-diff-run-unstaged-change () + "Normal: an unstaged edit appears as a real diff." + (test-gptel-tools-git-diff--with-repo + (lambda (dir) + (with-temp-file (expand-file-name "f.txt" dir) + (insert "changed\n")) + (let ((out (cj/gptel-git-diff--run dir))) + (should (string-match-p "^-original" out)) + (should (string-match-p "^\\+changed" out)))))) + +(ert-deftest test-gptel-tools-git-diff-run-narrow-to-file () + "Normal: FILE argument narrows the diff." + (test-gptel-tools-git-diff--with-repo + (lambda (dir) + (with-temp-file (expand-file-name "f.txt" dir) + (insert "changed\n")) + (with-temp-file (expand-file-name "g.txt" dir) + (insert "second file\n")) + (let ((out (cj/gptel-git-diff--run dir nil nil "f.txt"))) + (should (string-match-p "f.txt" out)) + (should-not (string-match-p "g.txt" out)))))) + +(provide 'test-gptel-tools-git-diff) +;;; test-gptel-tools-git-diff.el ends here diff --git a/tests/test-gptel-tools-git-log.el b/tests/test-gptel-tools-git-log.el new file mode 100644 index 00000000..708819b6 --- /dev/null +++ b/tests/test-gptel-tools-git-log.el @@ -0,0 +1,135 @@ +;;; test-gptel-tools-git-log.el --- Tests for git_log gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests run against real temp git repos under HOME via `process-file'. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'git_log) + +;; ---------- helpers + +(defun test-gptel-tools-git-log--with-repo (commit-count fn) + "Create a temp git repo under HOME with COMMIT-COUNT empty commits. +Call FN with the absolute path, clean up after." + (let* ((name (format ".test-gptel-tools-git-log-%s" + (format-time-string "%s%N"))) + (dir (expand-file-name name "~"))) + (unwind-protect + (progn + (make-directory dir) + (let ((default-directory dir)) + (call-process "git" nil nil nil "init" "--quiet") + (call-process "git" nil nil nil "config" "user.email" "test@x") + (call-process "git" nil nil nil "config" "user.name" "Test") + (dotimes (i commit-count) + (call-process "git" nil nil nil "commit" "--allow-empty" + "--quiet" "-m" (format "commit %d" i)))) + (funcall fn dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- effective-count + +(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-nil () + "Boundary: nil N → default count." + (should (= (cj/gptel-git-log--effective-count nil) + cj/gptel-git-log--default-count))) + +(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-non-integer () + "Boundary: non-integer N → default count." + (should (= (cj/gptel-git-log--effective-count "ten") + cj/gptel-git-log--default-count)) + (should (= (cj/gptel-git-log--effective-count 0.5) + cj/gptel-git-log--default-count))) + +(ert-deftest test-gptel-tools-git-log-effective-count-clamps-low () + "Boundary: N below 1 → default count." + (should (= (cj/gptel-git-log--effective-count 0) + cj/gptel-git-log--default-count)) + (should (= (cj/gptel-git-log--effective-count -5) + cj/gptel-git-log--default-count))) + +(ert-deftest test-gptel-tools-git-log-effective-count-caps-high () + "Boundary: N above max → max." + (should (= (cj/gptel-git-log--effective-count 1000) + cj/gptel-git-log--max-count))) + +(ert-deftest test-gptel-tools-git-log-effective-count-normal () + "Normal: a valid N passes through." + (should (= (cj/gptel-git-log--effective-count 5) 5))) + +;; ---------- validate-path + +(ert-deftest test-gptel-tools-git-log-validate-path-normal () + "Normal: validator accepts a git working tree." + (test-gptel-tools-git-log--with-repo + 1 + (lambda (dir) + (should (equal (cj/gptel-git-log--validate-path dir) dir))))) + +(ert-deftest test-gptel-tools-git-log-validate-path-error-outside-home () + "Error: path outside HOME signals." + (should-error (cj/gptel-git-log--validate-path "/etc"))) + +(ert-deftest test-gptel-tools-git-log-validate-path-error-not-a-repo () + "Error: directory outside any git working tree signals." + (let ((dir (make-temp-file + (expand-file-name ".test-gptel-tools-git-log-" "~") t))) + (unwind-protect + (should-error (cj/gptel-git-log--validate-path dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- run + +(ert-deftest test-gptel-tools-git-log-run-default-count () + "Normal: default count limits output to that many commits." + (test-gptel-tools-git-log--with-repo + 30 + (lambda (dir) + (let* ((out (cj/gptel-git-log--run dir)) + (lines (split-string (string-trim out) "\n"))) + (should (= (length lines) cj/gptel-git-log--default-count)))))) + +(ert-deftest test-gptel-tools-git-log-run-honors-n () + "Normal: an explicit N limits output to N commits." + (test-gptel-tools-git-log--with-repo + 10 + (lambda (dir) + (let* ((out (cj/gptel-git-log--run dir 3)) + (lines (split-string (string-trim out) "\n"))) + (should (= (length lines) 3)))))) + +(ert-deftest test-gptel-tools-git-log-run-empty-repo () + "Boundary: a repo with no commits returns the empty-result marker." + (let* ((name (format ".test-gptel-tools-git-log-empty-%s" + (format-time-string "%s%N"))) + (dir (expand-file-name name "~"))) + (unwind-protect + (progn + (make-directory dir) + (let ((default-directory dir)) + (call-process "git" nil nil nil "init" "--quiet")) + ;; git log on a no-commits repo errors in some versions, but + ;; our wrapper turns "no commits" into the no-match marker. + (let ((res (ignore-errors (cj/gptel-git-log--run dir)))) + ;; Either path is acceptable: error captured (nil) or the + ;; explicit "No commits matching" marker. + (should (or (null res) + (string-match-p "No commits" res))))) + (when (file-exists-p dir) (delete-directory dir t))))) + +(provide 'test-gptel-tools-git-log) +;;; test-gptel-tools-git-log.el ends here diff --git a/tests/test-gptel-tools-git-status.el b/tests/test-gptel-tools-git-status.el new file mode 100644 index 00000000..734abb31 --- /dev/null +++ b/tests/test-gptel-tools-git-status.el @@ -0,0 +1,98 @@ +;;; test-gptel-tools-git-status.el --- Tests for git_status gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests run against real temp git repos under HOME via `process-file'. +;; The tool is read-only so repos are torn down per test. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'git_status) + +;; ---------- helpers + +(defun test-gptel-tools-git-status--with-repo (fn) + "Create a temp git repo under HOME, call FN with its absolute path, clean up." + (let* ((name (format ".test-gptel-tools-git-status-%s" + (format-time-string "%s%N"))) + (dir (expand-file-name name "~"))) + (unwind-protect + (progn + (make-directory dir) + (let ((default-directory dir)) + (call-process "git" nil nil nil "init" "--quiet") + (call-process "git" nil nil nil "config" "user.email" "test@x") + (call-process "git" nil nil nil "config" "user.name" "Test") + (call-process "git" nil nil nil "commit" "--allow-empty" + "--quiet" "-m" "initial")) + (funcall fn dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- validate-path + +(ert-deftest test-gptel-tools-git-status-validate-path-normal () + "Normal: validator accepts a directory inside a git working tree." + (test-gptel-tools-git-status--with-repo + (lambda (dir) + (should (equal (cj/gptel-git-status--validate-path dir) dir))))) + +(ert-deftest test-gptel-tools-git-status-validate-path-error-outside-home () + "Error: path outside HOME signals." + (should-error (cj/gptel-git-status--validate-path "/etc"))) + +(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-directory () + "Error: path that's not a directory signals." + (let ((file (make-temp-file + (expand-file-name ".test-gptel-tools-git-status-" "~")))) + (unwind-protect + (should-error (cj/gptel-git-status--validate-path file)) + (when (file-exists-p file) (delete-file file))))) + +(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-repo () + "Error: directory outside any git working tree signals." + (let ((dir (make-temp-file + (expand-file-name ".test-gptel-tools-git-status-" "~") t))) + (unwind-protect + (should-error (cj/gptel-git-status--validate-path dir)) + (when (file-exists-p dir) (delete-directory dir t))))) + +;; ---------- run + +(ert-deftest test-gptel-tools-git-status-run-clean-tree () + "Normal: a clean repo returns the clean-tree marker." + (test-gptel-tools-git-status--with-repo + (lambda (dir) + (let ((out (cj/gptel-git-status--run dir))) + (should (string-match-p "Clean working tree" out)))))) + +(ert-deftest test-gptel-tools-git-status-run-dirty-tree-includes-file () + "Normal: an untracked file appears in the output." + (test-gptel-tools-git-status--with-repo + (lambda (dir) + (with-temp-file (expand-file-name "new.txt" dir) (insert "x")) + (let ((out (cj/gptel-git-status--run dir))) + (should (string-match-p "new.txt" out)) + (should (string-match-p "^\\?\\?" out)))))) + +(ert-deftest test-gptel-tools-git-status-run-includes-branch () + "Normal: the `--branch' line surfaces in the output." + (test-gptel-tools-git-status--with-repo + (lambda (dir) + (with-temp-file (expand-file-name "f.txt" dir) (insert "x")) + (let ((out (cj/gptel-git-status--run dir))) + (should (string-match-p "^## " out)))))) + +(provide 'test-gptel-tools-git-status) +;;; test-gptel-tools-git-status.el ends here |
