diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..918ed07 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,6 @@ +((emacs-lisp-mode + . ((eval . (progn + (require 'package) + (add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t) + (unless (assoc "magit" package-archive-contents) + (package-refresh-contents))))))) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94e5dd6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + emacs_version: ['28.1', '29.4', '30.2'] + steps: + - uses: actions/checkout@v4 + - uses: jcs090218/setup-emacs@master + with: + version: ${{ matrix.emacs_version }} + - uses: emacs-eask/setup-eask@master + with: + version: 'snapshot' + - run: eask package + - run: eask install-deps + - run: eask compile + - run: eask lint package + - run: eask test buttercup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..271496d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.elc +.deps +.compile +.eask/ +.cask/ +dist/ diff --git a/Easkfile b/Easkfile new file mode 100644 index 0000000..0cb16ab --- /dev/null +++ b/Easkfile @@ -0,0 +1,19 @@ +(package "magit-standup" + "0.1.0" + "Collect recent git commits for standup notes") + +(website-url "https://github.com/function-artisans/magit-standup") +(keywords "tools" "vc") + +(package-file "magit-standup.el") + +(source "gnu") +(source "melpa") + +(depends-on "emacs" "28.1") +(depends-on "magit" "4.5.0") +(depends-on "transient" "0.8.0") + +(development + (depends-on "package-lint") + (depends-on "buttercup")) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..83c5dc8 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: lint test clean + +.deps: Easkfile + eask install-deps + @touch .deps + +.compile: .deps magit-standup.el + eask compile + @touch .compile + +lint: .deps + eask lint package + +test: .compile + eask test buttercup + +clean: + eask clean all + @rm -f .deps .compile diff --git a/README.org b/README.org new file mode 100644 index 0000000..fd508d6 --- /dev/null +++ b/README.org @@ -0,0 +1,64 @@ +#+title: magit-standup +#+author: István Karaszi + +Collect recent git commits across multiple repositories and format them as org-mode standup notes. + +[[file:screenshots/standup-buffer.png]] + +* Features + +- Scans one or more git repositories for your recent commits +- Weekday-aware lookback: on Monday (and weekends) looks back to Friday; otherwise looks back 1 day +- Transient menu lets you override the since-date and repository list before running +- Output is an org-mode buffer with clickable commit links (via [[https://github.com/magit/orgit][orgit]] or [[https://orgmode.org/worg/org-contrib/org-git-link.html][org-git-link]]) +- Directories that aren't git repos are searched recursively for nested repos + +* Requirements + +- Emacs 28.1+ +- [[https://github.com/magit/magit][Magit]] 4.5.0+ +- [[https://github.com/magit/transient][Transient]] 0.8.0+ + +* Installation + +** With use-package and straight.el + +#+begin_src emacs-lisp +(use-package magit-standup + :straight (:host github :repo "function-artisans/magit-standup")) +#+end_src + +** Manual + +Clone the repository and add it to your =load-path=: + +#+begin_src emacs-lisp +(add-to-list 'load-path "/path/to/magit-standup") +(require 'magit-standup) +#+end_src + +* Usage + +Run =M-x magit-standup= to open the transient menu: + +- =-d= / =--since== — override the since-date (defaults to the weekday-aware computed date) +- =-r= / =--repos== — override the repository list +- =s= — show the standup buffer + +Press =s= to generate the standup notes in a =*magit-standup*= buffer. + +* Configuration + +All options can be customized via =M-x customize-group RET magit-standup=. + +| Variable | Default | Description | +|---------------------------------+---------+--------------------------------------------------------------------| +| =magit-standup-repos= | =nil= | List of directories to scan. When nil, uses current repo. | +| =magit-standup-repos-max-depth= | =1= | Max depth to search non-repo directories for nested repos. | +| =magit-standup-author= | =nil= | Author filter. When nil, uses =git config user.email=. | +| =magit-standup-since-days-ago= | =nil= | Fixed lookback override. When nil, weekday-aware logic. | +| =magit-standup-link-package= | =nil= | Link style: =orgit=, =org-git-link=, =none=, or nil (auto-detect). | + +* License + +GPL-3.0-or-later diff --git a/magit-standup.el b/magit-standup.el new file mode 100644 index 0000000..598f700 --- /dev/null +++ b/magit-standup.el @@ -0,0 +1,314 @@ +;;; magit-standup.el --- Collect recent git commits for standup notes -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Function Artisans, Ltd. + +;; Author: István Karaszi +;; Version: 0.1.0 +;; Package-Requires: ((emacs "28.1") (magit "4.5.0") (transient "0.8.0")) +;; Keywords: tools, vc +;; URL: https://github.com/function-artisans/magit-standup + +;; This file is not part of GNU Emacs. + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Collect recent git commits across multiple repositories and format +;; them as org-mode standup notes. On weekends and Mondays it +;; automatically looks back to Friday; on other weekdays it looks back +;; to the previous day. The list of repositories and the lookback +;; behavior are configurable. +;; +;; Usage: +;; M-x magit-standup +;; +;; Customize `magit-standup-repos' to specify which repositories to +;; scan. When nil, only the current repository is used. + +;;; Code: + +(require 'magit) +(require 'transient) + +(defgroup magit-standup nil + "Collect recent git commits for standup notes." + :group 'magit + :prefix "magit-standup-") + +(defcustom magit-standup-repos nil + "List of directory paths to collect commits from. +When nil, only the current repository is used. Entries that are +not git repositories are searched recursively for nested repos, +up to `magit-standup-repos-max-depth' levels deep." + :type '(repeat directory) + :group 'magit-standup) + +(defcustom magit-standup-repos-max-depth 1 + "Maximum depth to search for git repositories in non-repo directories. +When nil, search with unlimited depth." + :type '(choice (const :tag "Unlimited" nil) + (integer :tag "Max depth")) + :group 'magit-standup) + +(defcustom magit-standup-author nil + "Author name or email to filter commits by. +When nil, the result of `git config user.email' is used." + :type '(choice (const :tag "From git config" nil) + (string :tag "Author name/email")) + :group 'magit-standup) + +(defcustom magit-standup-link-package nil + "Package to use for linking commit hashes in org output. +When nil, auto-detect by checking if `orgit' or `org-git-link' +is loaded. When `orgit', use orgit-rev links. When +`org-git-link', use git links. When `none', plain text +without links." + :type '(choice (const :tag "Auto-detect" nil) + (const :tag "orgit" orgit) + (const :tag "org-git-link" org-git-link) + (const :tag "Plain text" none)) + :group 'magit-standup) + +(defcustom magit-standup-since-days-ago nil + "Override for how many days back to look. +When nil, automatic weekday-aware logic is used: on Monday look +back to Friday (3 days), otherwise look back 1 day." + :type '(choice (const :tag "Automatic" nil) + (integer :tag "Days ago")) + :group 'magit-standup) + +(defconst magit-standup--buffer-name "*magit-standup*") + +(defun magit-standup--since-date () + "Return the \"since\" date string for filtering commits. +If `magit-standup-since-days-ago' is set, use it. Otherwise, +on weekends look back to Friday; on Monday look back to Friday +\(3 days); on other weekdays look back 1 day." + (let* ((now (current-time)) + (day (string-to-number (format-time-string "%u" now))) + (days-ago (cond (magit-standup-since-days-ago magit-standup-since-days-ago) + ((<= 6 day) (- day 5)) + ((= 1 day) 3) + (t 1)))) + (format-time-string "%Y-%m-%d" + (time-subtract now + (days-to-time days-ago))))) + +(defun magit-standup--find-repos (dir depth) + "Recursively find git repositories under DIR up to DEPTH levels. +When DEPTH is nil, search with unlimited depth. Hidden +directories are skipped." + (cond + ((magit-git-repo-p dir) (list dir)) + ((and depth (<= depth 0)) nil) + (t (mapcan (lambda (child) + (when (and (file-directory-p child) + (not (string-prefix-p "." (file-name-nondirectory child)))) + (magit-standup--find-repos child (and depth (1- depth))))) + (directory-files dir t nil t))))) + +(defun magit-standup--resolve-repos (dirs) + "Expand DIRS to a list of git repository paths. +Entries that are already git repos are kept as-is. Others are +searched recursively up to `magit-standup-repos-max-depth'." + (mapcan (lambda (dir) + (magit-standup--find-repos dir magit-standup-repos-max-depth)) + dirs)) + +(defun magit-standup--detect-link-package () + "Detect which git-link package is available. +Returns `orgit' if orgit is loaded, `org-git-link' if +org-git-link is loaded, or nil if neither is available." + (cond + ((featurep 'orgit) 'orgit) + ((featurep 'org-git-link) 'org-git-link))) + +(defun magit-standup--link-prefix (package) + "Return the org link type string for PACKAGE. +PACKAGE should be `orgit', `org-git-link', or nil." + (pcase package + ('orgit "orgit-rev") + ('org-git-link "git") + (_ nil))) + +(defun magit-standup--format-commit (repo-path line &optional link-prefix) + "Format a commit LINE, optionally as an org link. +REPO-PATH is the repository directory. LINE is expected to have +the hash separated from the rest by a null byte. LINK-PREFIX is +the org link prefix string, or nil for plain text." + (let* ((parts (split-string line "\0")) + (hash (car parts)) + (rest (cadr parts))) + (format "%s %s" + (if link-prefix + (format "[[%s:%s::%s][%s]]" link-prefix repo-path hash hash) + hash) + rest))) + +(defun magit-standup--resolve-author (repo-path) + "Return the author string to filter commits by in REPO-PATH. +Uses `magit-standup-author' if set, otherwise falls back to +`git config user.email' in REPO-PATH." + (let ((default-directory (file-name-as-directory repo-path))) + (or magit-standup-author + (magit-git-string "config" "user.email") + (user-error "Cannot determine author for %s; set `magit-standup-author' or git config user.email" repo-path)))) + +(defun magit-standup--collect-commits (repo-path since-date) + "Collect commits from REPO-PATH since SINCE-DATE. +Returns an alist of (BRANCH-NAME . COMMITS) where COMMITS is a +list of raw commit strings with hash and message separated by a +null byte." + (let* ((default-directory (file-name-as-directory repo-path)) + (author (magit-standup--resolve-author repo-path)) + (branches (magit-git-lines "branch" "--format=%(refname:short)"))) + (mapcar (lambda (branch) + (cons branch + (magit-git-lines "log" + "--no-merges" + "--format=%h%x00%s <%ai> - %aN" + (concat "--after=" since-date) + (concat "--author=" author) + branch))) + branches))) + +(defun magit-standup--format-branch-commits (repo-path branch-commits &optional link-prefix) + "Format BRANCH-COMMITS for REPO-PATH as org text. +BRANCH-COMMITS is a cons of (BRANCH-NAME . COMMITS). +LINK-PREFIX is the org link prefix string, or nil for plain text. +Returns nil when BRANCH-COMMITS has no commits." + (when (cdr branch-commits) + (format "** ~%s~\n%s\n" + (car branch-commits) + (mapconcat + (lambda (c) + (format "- %s" (magit-standup--format-commit + repo-path c link-prefix))) + (cdr branch-commits) "\n")))) + +(defun magit-standup--format-org (repo-commits &optional link-prefix) + "Format REPO-COMMITS as `org-mode' text. +REPO-COMMITS is an alist of (REPO-PATH . BRANCH-COMMITS) where +BRANCH-COMMITS is an alist of (BRANCH-NAME . COMMITS). +LINK-PREFIX is the org link prefix string, or nil for plain text." + (mapconcat + (lambda (entry) + (let* ((repo-path (car entry)) + (repo-name (file-name-nondirectory + (directory-file-name repo-path))) + (formatted (delq nil + (mapcar + (lambda (bc) + (magit-standup--format-branch-commits + repo-path bc link-prefix)) + (cdr entry))))) + (when formatted + (format "* [[file:%s][%s]]\n\n%s\n" + repo-path repo-name + (mapconcat #'identity formatted "\n"))))) + repo-commits "")) + +(defun magit-standup--gather (&optional since-date repos) + "Gather recent commits across all configured repositories. +SINCE-DATE, when non-nil, overrides the computed since date. +REPOS, when non-nil, overrides `magit-standup-repos'. +Returns an alist of (REPO-PATH . BRANCH-COMMITS) suitable for +`magit-standup--format-org'." + (let* ((since-date (or since-date (magit-standup--since-date))) + (repos (or repos + (magit-standup--resolve-repos magit-standup-repos) + (list (magit-toplevel))))) + (mapcar (lambda (repo) + (cons repo + (magit-standup--collect-commits repo since-date))) + repos))) + +(defun magit-standup-quit () + "Quit the `*magit-standup*' buffer and kill it." + (interactive) + (when-let ((win (get-buffer-window magit-standup--buffer-name))) + (quit-window t win))) + +(defun magit-standup--display (repo-commits) + "Display REPO-COMMITS in the `*magit-standup*' buffer. +REPO-COMMITS is an alist as returned by `magit-standup--gather'." + (let* ((link-package (or magit-standup-link-package + (magit-standup--detect-link-package))) + (buf (get-buffer-create magit-standup--buffer-name)) + (link-prefix (magit-standup--link-prefix link-package))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (magit-standup--format-org repo-commits link-prefix))) + (goto-char (point-min)) + (org-mode) + (read-only-mode 1) + (when (bound-and-true-p evil-mode) + (evil-local-set-key 'normal "q" #'magit-standup-quit))) + (pop-to-buffer buf))) + +(defun magit-standup--default-repos () + "Return resolved repo paths as a list of normalized directory names." + (mapcar (lambda (d) (directory-file-name (expand-file-name d))) + (or (magit-standup--resolve-repos magit-standup-repos) + (when-let ((top (magit-toplevel))) + (list top))))) + +(defun magit-standup--read-repos (prompt _initial-input _history) + "Read repo directories with `completing-read-multiple'. +PROMPT is shown to the user. Returns a comma-separated string." + (string-join (completing-read-multiple prompt (magit-standup--default-repos)) ",")) + +(transient-define-infix magit-standup--since () + :description "Since date" + :class 'transient-option + :shortarg "-d" + :argument "--since=" + :reader #'transient-read-date + :init-value (lambda (obj) + (unless (oref obj value) + (oset obj value (magit-standup--since-date))))) + +(transient-define-infix magit-standup--repos () + :description "Repositories" + :class 'transient-option + :shortarg "-r" + :argument "--repos=" + :reader #'magit-standup--read-repos) + +(transient-define-suffix magit-standup--run () + "Run the standup with the selected options." + :key "s" + :description "Show standup" + (interactive) + (let* ((args (transient-args 'magit-standup)) + (since (transient-arg-value "--since=" args)) + (repos-str (transient-arg-value "--repos=" args)) + (repos (when repos-str (split-string repos-str ",")))) + (magit-standup--display + (magit-standup--gather since repos)))) + +;;;###autoload (autoload 'magit-standup "magit-standup" nil t) +(transient-define-prefix magit-standup () + "Show a menu for generating standup notes." + ["Options" + (magit-standup--since) + (magit-standup--repos)] + ["Actions" + ("s" "Show standup" magit-standup--run)]) + +(provide 'magit-standup) + +;;; magit-standup.el ends here diff --git a/screenshots/standup-buffer.png b/screenshots/standup-buffer.png new file mode 100644 index 0000000..1e9c86d Binary files /dev/null and b/screenshots/standup-buffer.png differ diff --git a/test/magit-standup-test.el b/test/magit-standup-test.el new file mode 100644 index 0000000..e5cd09b --- /dev/null +++ b/test/magit-standup-test.el @@ -0,0 +1,287 @@ +;;; magit-standup-test.el --- Tests for magit-standup -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Buttercup tests for magit-standup. + +;;; Code: + +(require 'buttercup) +(require 'magit-standup) + +(describe "magit-standup--since-date" + (dolist (case '((:desc "looks back 3 days on Monday (to Friday)" :date "2026-01-05" :expected "2026-01-02") + (:desc "looks back 1 day on Tuesday" :date "2026-01-06" :expected "2026-01-05") + (:desc "looks back 1 day on Wednesday" :date "2026-01-07" :expected "2026-01-06") + (:desc "looks back to Friday on Saturday (1 day)" :date "2026-01-10" :expected "2026-01-09") + (:desc "looks back to Friday on Sunday (2 days)" :date "2026-01-11" :expected "2026-01-09") + (:desc "uses custom override when set" :date "2026-01-07" :expected "2025-12-31" :override 7))) + (it (plist-get case :desc) + (let ((magit-standup-since-days-ago (plist-get case :override)) + (current-time (date-to-time (concat (plist-get case :date) " 12:00:00")))) + (cl-letf (((symbol-function 'current-time) + (lambda () current-time))) + (expect (magit-standup--since-date) :to-equal (plist-get case :expected))))))) + +(describe "magit-standup--detect-link-package" + (it "returns orgit when orgit is loaded" + (cl-letf (((symbol-function 'featurep) + (lambda (f) (eq f 'orgit)))) + (expect (magit-standup--detect-link-package) :to-be 'orgit))) + + (it "returns org-git-link when org-git-link is loaded" + (cl-letf (((symbol-function 'featurep) + (lambda (f) (eq f 'org-git-link)))) + (expect (magit-standup--detect-link-package) :to-be 'org-git-link))) + + (it "returns nil when nothing is loaded" + (cl-letf (((symbol-function 'featurep) + (lambda (_) nil))) + (expect (magit-standup--detect-link-package) :to-be nil)))) + +(describe "magit-standup--link-prefix" + (it "returns orgit-rev for orgit" + (expect (magit-standup--link-prefix 'orgit) :to-equal "orgit-rev")) + + (it "returns git for org-git-link" + (expect (magit-standup--link-prefix 'org-git-link) :to-equal "git")) + + (it "returns nil for none" + (expect (magit-standup--link-prefix 'none) :to-be nil)) + + (it "returns nil for nil" + (expect (magit-standup--link-prefix nil) :to-be nil))) + +(describe "magit-standup--format-commit" + (it "formats with orgit-rev link" + (expect (magit-standup--format-commit "/home/user/repo" "abc123\0Fix bug <2026-01-05> Alice" "orgit-rev") + :to-equal "[[orgit-rev:/home/user/repo::abc123][abc123]] Fix bug <2026-01-05> Alice")) + + (it "formats with git link" + (expect (magit-standup--format-commit "/home/user/repo" "abc123\0Fix bug <2026-01-05> Alice" "git") + :to-equal "[[git:/home/user/repo::abc123][abc123]] Fix bug <2026-01-05> Alice")) + + (it "formats as plain text when link-prefix is omitted" + (expect (magit-standup--format-commit "/home/user/repo" "abc123\0Fix bug <2026-01-05> Alice") + :to-equal "abc123 Fix bug <2026-01-05> Alice"))) + +(describe "magit-standup--format-branch-commits" + (it "formats a branch with commits" + (expect (magit-standup--format-branch-commits + "/home/user/repo" '("main" . ("abc\0Fix bug" "def\0Add feature"))) + :to-equal "** ~main~\n- abc Fix bug\n- def Add feature\n")) + + (it "formats with a link prefix" + (expect (magit-standup--format-branch-commits + "/home/user/repo" '("main" . ("abc\0Fix bug")) "orgit-rev") + :to-equal "** ~main~\n- [[orgit-rev:/home/user/repo::abc][abc]] Fix bug\n")) + + (it "returns nil for a branch with no commits" + (expect (magit-standup--format-branch-commits + "/home/user/repo" '("stale-branch")) + :to-be nil))) + +(describe "magit-standup--resolve-author" + (it "uses magit-standup-author when set" + (let ((magit-standup-author "alice")) + (spy-on 'magit-git-string) + (magit-standup--resolve-author "/tmp/repo") + (expect 'magit-git-string :not :to-have-been-called))) + + (it "falls back to git config user.email" + (let ((magit-standup-author nil)) + (spy-on 'magit-git-string :and-return-value "bob@example.com") + (expect (magit-standup--resolve-author "/tmp/repo") + :to-equal "bob@example.com") + (expect 'magit-git-string + :to-have-been-called-with "config" "user.email"))) + + (it "signals error when no author can be determined" + (let ((magit-standup-author nil)) + (spy-on 'magit-git-string :and-return-value nil) + (expect (magit-standup--resolve-author "/tmp/repo") + :to-throw 'user-error)))) + +(describe "magit-standup--collect-commits" + (it "sets default-directory to the repo path" + (let ((magit-standup-author "alice") + captured-dirs) + (spy-on 'magit-git-lines :and-call-fake + (lambda (&rest _) + (push default-directory captured-dirs) + nil)) + (magit-standup--collect-commits "/tmp/my-repo" "2026-01-05") + (expect captured-dirs :not :to-be nil) + (dolist (dir captured-dirs) + (expect dir :to-equal "/tmp/my-repo/"))))) + +(describe "magit-standup--resolve-repos" + :var (tmpdir) + + (before-each + (setq tmpdir (make-temp-file "standup-test-" t)) + ;; Create a git repo at tmpdir/repo-a + (let ((repo-a (expand-file-name "repo-a" tmpdir))) + (make-directory repo-a) + (make-directory (expand-file-name ".git" repo-a))) + ;; Create a git repo at tmpdir/repo-b + (let ((repo-b (expand-file-name "repo-b" tmpdir))) + (make-directory repo-b) + (make-directory (expand-file-name ".git" repo-b))) + ;; Create a non-repo dir with a nested repo at tmpdir/parent/nested + (let ((nested (expand-file-name "parent/nested" tmpdir))) + (make-directory nested t) + (make-directory (expand-file-name ".git" nested))) + ;; Create a hidden dir with a repo inside (should be skipped) + (let ((hidden (expand-file-name ".hidden/secret-repo" tmpdir))) + (make-directory hidden t) + (make-directory (expand-file-name ".git" hidden)))) + + (after-each + (delete-directory tmpdir t)) + + (it "returns nil for nil input" + (let ((magit-standup-repos-max-depth 1)) + (expect (magit-standup--resolve-repos nil) :to-be nil))) + + (it "returns a git repo directory as-is" + (let ((magit-standup-repos-max-depth 1) + (repo-a (expand-file-name "repo-a" tmpdir))) + (expect (magit-standup--resolve-repos (list repo-a)) + :to-equal (list repo-a)))) + + (it "discovers repos in immediate subdirectories" + (let ((magit-standup-repos-max-depth 1)) + (let ((result (sort (magit-standup--resolve-repos (list tmpdir)) #'string<))) + (expect result :to-equal + (sort (list (expand-file-name "repo-a" tmpdir) + (expand-file-name "repo-b" tmpdir)) + #'string<))))) + + (it "discovers nested repos with sufficient depth" + (let ((magit-standup-repos-max-depth 2)) + (let ((result (sort (magit-standup--resolve-repos (list tmpdir)) #'string<))) + (expect result :to-contain + (expand-file-name "parent/nested" tmpdir))))) + + (it "stops at depth 0" + (let ((magit-standup-repos-max-depth 0)) + (expect (magit-standup--resolve-repos (list tmpdir)) :to-be nil))) + + (it "searches unlimited depth when max-depth is nil" + (let ((magit-standup-repos-max-depth nil)) + (let ((result (magit-standup--resolve-repos (list tmpdir)))) + (expect result :to-contain + (expand-file-name "parent/nested" tmpdir))))) + + (it "skips hidden directories" + (let ((magit-standup-repos-max-depth nil)) + (let ((result (magit-standup--resolve-repos (list tmpdir)))) + (expect result :not :to-contain + (expand-file-name ".hidden/secret-repo" tmpdir)))))) + +(describe "magit-standup--format-org" + (it "formats branch commits with subheadings" + (expect (magit-standup--format-org + '(("/home/user/my-repo" . (("main" . ("abc123\0Fix bug" + "def456\0Add feature"))))) + nil) + :to-equal + "* [[file:/home/user/my-repo][my-repo]]\n\n** ~main~\n- abc123 Fix bug\n- def456 Add feature\n\n")) + + (it "omits repos when all branches have no commits" + (expect (magit-standup--format-org + '(("/home/user/empty-repo" . (("main") ("develop")))) + nil) + :to-equal "")) + + (it "skips branches with no commits" + (expect (magit-standup--format-org + '(("/home/user/my-repo" . (("main" . ("abc\0Fix thing")) + ("stale-branch")))) + nil) + :to-equal + "* [[file:/home/user/my-repo][my-repo]]\n\n** ~main~\n- abc Fix thing\n\n")) + + (it "shows multiple branches under one repo" + (expect (magit-standup--format-org + '(("/home/user/my-repo" . (("main" . ("abc\0Fix thing")) + ("feature" . ("def\0Add thing"))))) + nil) + :to-equal + (concat "* [[file:/home/user/my-repo][my-repo]]\n\n** ~main~\n- abc Fix thing\n" + "\n** ~feature~\n- def Add thing\n\n"))) + + (it "separates multiple repos with blank lines" + (expect (magit-standup--format-org + '(("/home/user/repo-a" . (("main" . ("abc\0Fix thing")))) + ("/home/user/repo-b" . (("develop" . ("def\0Other thing"))))) + nil) + :to-equal + (concat "* [[file:/home/user/repo-a][repo-a]]\n\n** ~main~\n- abc Fix thing\n\n" + "* [[file:/home/user/repo-b][repo-b]]\n\n** ~develop~\n- def Other thing\n\n"))) + + (it "applies link-prefix to commits" + (expect (magit-standup--format-org + '(("/home/user/my-repo" . (("main" . ("abc\0Fix thing"))))) + "orgit-rev") + :to-equal + "* [[file:/home/user/my-repo][my-repo]]\n\n** ~main~\n- [[orgit-rev:/home/user/my-repo::abc][abc]] Fix thing\n\n"))) + +(describe "magit-standup--gather" + (before-each + (spy-on 'magit-standup--since-date :and-return-value "2026-01-05") + (spy-on 'magit-standup--collect-commits :and-return-value + '(("main" . ("abc Fix thing")))) + (spy-on 'magit-standup--resolve-repos :and-call-fake #'identity)) + + (it "uses magit-standup-repos when set" + (let ((magit-standup-repos '("/tmp/a" "/tmp/b"))) + (let ((result (magit-standup--gather))) + (expect (mapcar #'car result) :to-equal '("/tmp/a" "/tmp/b"))))) + + (it "falls back to magit-toplevel when repos is nil" + (let ((magit-standup-repos nil)) + (spy-on 'magit-toplevel :and-return-value "/home/user/my-project") + (let ((result (magit-standup--gather))) + (expect (mapcar #'car result) :to-equal '("/home/user/my-project"))))) + + (it "uses provided since-date instead of computing one" + (let ((magit-standup-repos '("/tmp/a"))) + (magit-standup--gather "2026-02-01") + (expect 'magit-standup--since-date :not :to-have-been-called) + (expect 'magit-standup--collect-commits + :to-have-been-called-with "/tmp/a" "2026-02-01"))) + + (it "uses provided repos instead of configured ones" + (let ((magit-standup-repos '("/tmp/should-not-use"))) + (let ((result (magit-standup--gather nil '("/tmp/x" "/tmp/y")))) + (expect 'magit-standup--resolve-repos :not :to-have-been-called) + (expect (mapcar #'car result) :to-equal '("/tmp/x" "/tmp/y"))))) + + (it "uses both overrides together" + (let ((magit-standup-repos '("/tmp/ignored"))) + (magit-standup--gather "2026-03-01" '("/tmp/override")) + (expect 'magit-standup--since-date :not :to-have-been-called) + (expect 'magit-standup--resolve-repos :not :to-have-been-called) + (expect 'magit-standup--collect-commits + :to-have-been-called-with "/tmp/override" "2026-03-01")))) + +(describe "magit-standup--display" + (after-each + (when-let ((buf (get-buffer magit-standup--buffer-name))) + (kill-buffer buf))) + + (it "creates a buffer with org-mode content" + (let ((magit-standup-link-package 'none)) + (magit-standup--display + '(("/home/user/my-repo" . (("main" . ("abc\0Fix bug")))))) + (let ((buf (get-buffer magit-standup--buffer-name))) + (expect buf :not :to-be nil) + (with-current-buffer buf + (expect major-mode :to-be 'org-mode) + (expect buffer-read-only :to-be t) + (expect (buffer-string) :to-match "my-repo") + (expect (buffer-string) :to-match "abc Fix bug")))))) + +;;; magit-standup-test.el ends here