aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/coverage-core.el58
-rw-r--r--tests/test-coverage-core--changed-lines.el56
-rw-r--r--tests/test-coverage-core--command.el7
3 files changed, 96 insertions, 25 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index b6723eca..93979530 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -4,7 +4,7 @@
;;; Commentary:
;; Language-agnostic core for diff-aware coverage reporting.
;;
-;; Reads an LCOV file, shells to git diff at a selectable scope,
+;; Reads an LCOV file, invokes git diff at a selectable scope,
;; intersects the results, and displays a report buffer. Languages
;; plug in via the backend registry (see `cj/coverage-backends').
;;
@@ -13,6 +13,7 @@
;;; Code:
(require 'seq)
+(require 'subr-x)
(defvar cj/coverage-backends nil
"Registry of coverage backends in priority order.
@@ -196,25 +197,54 @@ empty hash table. Malformed hunk headers are skipped silently."
(forward-line 1)))
result))
+(defun cj/--coverage-git-string (&rest args)
+ "Run git with ARGS and return its stdout as a string.
+Signals `user-error' when git exits non-zero."
+ (with-temp-buffer
+ (let ((status (apply #'process-file "git" nil (current-buffer) nil args))
+ (output (buffer-string)))
+ (unless (zerop status)
+ (user-error "git %s failed with status %s: %s"
+ (string-join args " ")
+ status
+ (string-trim output)))
+ output)))
+
+(defun cj/--coverage-git-merge-base (base)
+ "Return the merge-base between HEAD and BASE."
+ (let ((merge-base (string-trim
+ (cj/--coverage-git-string "merge-base" "HEAD" base))))
+ (unless (not (string-empty-p merge-base))
+ (user-error "git merge-base HEAD %s returned no commit" base))
+ merge-base))
+
+(defun cj/--coverage-git-diff (&rest args)
+ "Return git diff output for ARGS plus --unified=0."
+ (apply #'cj/--coverage-git-string
+ (append (list "diff") args (list "--unified=0"))))
+
(defun cj/--coverage-changed-lines (scope &optional base)
"Return a hash table of files to changed line numbers for SCOPE.
SCOPE is one of the symbols `working-tree', `staged', `branch-vs-main',
or `branch-vs-parent'. For `branch-vs-parent', BASE is the ref to
compare against; if nil, falls back to the tracked upstream @{upstream}.
Signals `user-error' for any other SCOPE."
- (let ((cmd (cond
- ((eq scope 'working-tree)
- "git diff HEAD --unified=0")
- ((eq scope 'staged)
- "git diff --cached --unified=0")
- ((eq scope 'branch-vs-main)
- "git diff $(git merge-base HEAD main)..HEAD --unified=0")
- ((eq scope 'branch-vs-parent)
- (format "git diff $(git merge-base HEAD %s)..HEAD --unified=0"
- (or base "@{upstream}")))
- (t
- (user-error "Unknown coverage scope: %s" scope)))))
- (cj/--coverage-parse-diff-output (shell-command-to-string cmd))))
+ (let ((output
+ (pcase scope
+ ('working-tree
+ (cj/--coverage-git-diff "HEAD"))
+ ('staged
+ (cj/--coverage-git-diff "--cached"))
+ ('branch-vs-main
+ (cj/--coverage-git-diff
+ (format "%s..HEAD" (cj/--coverage-git-merge-base "main"))))
+ ('branch-vs-parent
+ (cj/--coverage-git-diff
+ (format "%s..HEAD"
+ (cj/--coverage-git-merge-base (or base "@{upstream}")))))
+ (_
+ (user-error "Unknown coverage scope: %s" scope)))))
+ (cj/--coverage-parse-diff-output output)))
(defun cj/--coverage-hash-keys-sorted (table)
"Return a sorted list of TABLE's integer keys."
diff --git a/tests/test-coverage-core--changed-lines.el b/tests/test-coverage-core--changed-lines.el
index dcf37603..f271fde1 100644
--- a/tests/test-coverage-core--changed-lines.el
+++ b/tests/test-coverage-core--changed-lines.el
@@ -3,7 +3,7 @@
;;; Commentary:
;; Unit tests for:
;; `cj/--coverage-parse-diff-output' (pure parser over git-diff text)
-;; `cj/--coverage-changed-lines' (scope → hash table, shells to git)
+;; `cj/--coverage-changed-lines' (scope → hash table, invokes git by argv)
;;
;; The parser takes the output of `git diff --unified=0' and returns
;; a hash table of file → set of changed (added) line numbers in the
@@ -173,16 +173,54 @@ Binary files a/image.png and b/image.png differ
(should (gethash 10 lines))
(should (gethash 11 lines))))
-;;; Smoke test — changed-lines (stubbed git invocation)
+;;; Smoke tests — changed-lines (stubbed git invocation)
(ert-deftest test-coverage-changed-lines-working-tree-stubbed ()
- "Smoke: scope dispatches, shell is stubbed, parser is applied to the result."
- (cl-letf (((symbol-function 'shell-command-to-string)
- (lambda (_cmd) test-coverage-diff--simple-single-file)))
- (let* ((result (cj/--coverage-changed-lines 'working-tree))
- (lines (gethash "foo.el" result)))
- (should (= 1 (hash-table-count result)))
- (should (= 3 (hash-table-count lines))))))
+ "Smoke: working-tree scope invokes git diff via argv and parses the result."
+ (let (seen-calls)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program _infile destination _display &rest args)
+ (push (cons program args) seen-calls)
+ (with-current-buffer destination
+ (insert test-coverage-diff--simple-single-file))
+ 0)))
+ (let* ((result (cj/--coverage-changed-lines 'working-tree))
+ (lines (gethash "foo.el" result)))
+ (should (equal (nreverse seen-calls)
+ '(("git" "diff" "HEAD" "--unified=0"))))
+ (should (= 1 (hash-table-count result)))
+ (should (= 3 (hash-table-count lines)))))))
+
+(ert-deftest test-coverage-changed-lines-branch-vs-parent-computes-merge-base ()
+ "Branch scopes should compute merge-base separately before diffing."
+ (let (seen-calls)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program _infile destination _display &rest args)
+ (push (cons program args) seen-calls)
+ (with-current-buffer destination
+ (insert
+ (pcase args
+ (`("merge-base" "HEAD" "feature/base") "abc123\n")
+ (`("diff" "abc123..HEAD" "--unified=0")
+ test-coverage-diff--simple-single-file)
+ (_ ""))))
+ 0)))
+ (let* ((result (cj/--coverage-changed-lines 'branch-vs-parent "feature/base"))
+ (lines (gethash "foo.el" result)))
+ (should (equal (nreverse seen-calls)
+ '(("git" "merge-base" "HEAD" "feature/base")
+ ("git" "diff" "abc123..HEAD" "--unified=0"))))
+ (should (= 3 (hash-table-count lines)))))))
+
+(ert-deftest test-coverage-changed-lines-git-failure-errors-clearly ()
+ "Git failures should surface as user-error messages."
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (with-current-buffer destination
+ (insert "fatal: not a git repository\n"))
+ 128)))
+ (should-error (cj/--coverage-changed-lines 'working-tree)
+ :type 'user-error)))
(ert-deftest test-coverage-changed-lines-unknown-scope-errors ()
"Error: an unknown scope symbol signals user-error."
diff --git a/tests/test-coverage-core--command.el b/tests/test-coverage-core--command.el
index d7b2c1cc..274938c8 100644
--- a/tests/test-coverage-core--command.el
+++ b/tests/test-coverage-core--command.el
@@ -66,8 +66,11 @@ uncovered-line markers."
(cj/coverage-register-backend test-backend)
(cl-letf (((symbol-function 'completing-read)
(lambda (&rest _) "Staged — about to commit"))
- ((symbol-function 'shell-command-to-string)
- (lambda (_) diff-output))
+ ((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (with-current-buffer destination
+ (insert diff-output))
+ 0))
((symbol-function 'cj/--coverage-project-root)
(lambda () tmp-root))
((symbol-function 'display-buffer) #'identity))