diff options
Diffstat (limited to 'gptel-tools')
| -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 |
3 files changed, 277 insertions, 0 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 |
