diff options
| -rw-r--r-- | modules/coverage-core.el | 58 | ||||
| -rw-r--r-- | tests/test-coverage-core--changed-lines.el | 56 | ||||
| -rw-r--r-- | tests/test-coverage-core--command.el | 7 |
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)) |
