diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-30 13:43:25 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-30 13:43:25 -0400 |
| commit | f67e72430845236ab5ed4ca00ba13afe87eda53a (patch) | |
| tree | 395c087693a99d5e2db93c8e0dfe42703bb59cbc /.ai/scripts/tests | |
| parent | 324a52b511b8c54ec0e4e706df7a1d4568447efc (diff) | |
| download | rulesets-f67e72430845236ab5ed4ca00ba13afe87eda53a.tar.gz rulesets-f67e72430845236ab5ed4ca00ba13afe87eda53a.zip | |
feat(todo-cleanup): age the Resolved section out to a tracked archive
A project's in-file Resolved section grew without bound: --archive-done only moved closures from Open Work into Resolved, same file. .emacs.d's todo.org reached 768KB that way. So --archive-done now has a second step: a Resolved subtree closed longer ago than tc-archive-retain-days (default 7), or with no parseable CLOSED date, moves out to archive/task-archive.org beside the todo file. Only the last week of closures stays in the file. The step honors --check, and tc-archive-retain-days nil disables it.
I added a guard the proposal lacked: the archive inherits the todo file's gitignore status. gitignore-mode projects gitignore todo.org, so without this the aging step would shed previously-private task history into a tracked archive, a leak on any public repo. When the todo file is gitignored and the archive isn't, the script adds the archive path to .gitignore before the first write. Track-mode projects leave both tracked. Covered by two tests in a temp git repo, one per branch.
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 206 |
1 files changed, 202 insertions, 4 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index ad9260b..e569d9a 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -30,16 +30,20 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done t tc-sync-child-priority nil - tc-current-file nil)) + tc-current-file nil + ;; Aging step OFF by default so the in-file-move tests are unaffected by + ;; the wall clock; the aging harness re-enables it with fixed params. + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--reset-sync (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority t - tc-current-file nil)) + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -355,6 +359,200 @@ from the heading line through (not including) the next level-1 heading or EOF." (should (tc-test--has (plist-get out :report) "skipped")))) ;;; --------------------------------------------------------------------------- +;;; --archive-done file-aging: keep last week in-file, move older to task-archive + +(defun tc-test--age (content &optional opts) + "Run `--archive-done' with the file-aging step enabled. +OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref +\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a +temp todo file and points `tc-archive-file' at a not-yet-existing temp archive. +Returns a plist: :result (todo contents), :archive (archive-file contents or +nil), :archived (in-file move count), :to-file (aged count), :issues — all from +the last run." + (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7)) + (ref (plist-get opts :ref)) + (runs (or (plist-get opts :runs) 1)) + (check (plist-get opts :check)) + (todo (make-temp-file "tc-age-todo-" nil ".org")) + (adir (make-temp-file "tc-age-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + last) + (unwind-protect + (progn + (with-temp-file todo (insert content)) + (dotimes (_ runs) + (tc-test--reset check) + (setq tc-archive-retain-days retain + tc-archive-reference-date ref + tc-archive-file afile) + (tc-process-file todo) + (setq last (list :archived tc-archived :to-file tc-archived-to-file + :issues tc-issues)) + (tc-test--drop-buffer todo)) + (append + last + (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string)) + :archive (and (file-readable-p afile) + (with-temp-buffer (insert-file-contents afile) + (buffer-string)))))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff +;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file. +(defconst tc-test--age-resolved "\ +* Age Open Work +** TODO [#A] still open +* Age Resolved +** DONE [#B] recent within window +CLOSED: [2026-06-25 Thu] +recent body +** DONE [#C] old beyond window +CLOSED: [2026-05-01 Fri] +old body line +** CANCELLED [#C] old cancelled too +CLOSED: [2026-04-15 Wed] +** DONE [#B] exactly at cutoff stays +CLOSED: [2026-06-22 Sun] +** DONE [#C] undated no-date archived +no closed date in this body +") + +(defconst tc-test--age-straggler "\ +* Age Open Work +** TODO [#A] still open +** DONE [#C] old straggler +CLOSED: [2026-03-01 Sun] +straggler body +* Age Resolved +** DONE [#B] recent stays +CLOSED: [2026-06-26 Fri] +") + +(ert-deftest tc-age-moves-old-and-undated-resolved () + "Normal: closed-beyond-window AND undated subtrees leave the file; only those +closed within the window (cutoff inclusive) stay." + (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29)))) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should (= 3 (plist-get out :to-file))) + (should-not (tc-test--has resolved "old beyond window")) + (should-not (tc-test--has resolved "old cancelled too")) + (should-not (tc-test--has resolved "undated no-date archived")) + (should (tc-test--has resolved "recent within window")) + (should (tc-test--has resolved "exactly at cutoff stays")) + (should arch) + (should (tc-test--has arch "Resolved (archived)")) + (should (tc-test--has arch "old beyond window")) + (should (tc-test--has arch "old body line")) + (should (tc-test--has arch "old cancelled too")) + (should (tc-test--has arch "undated no-date archived")) + (should-not (tc-test--has arch "recent within window")))) + +(ert-deftest tc-age-disabled-when-retain-nil () + "Boundary: nil retain disables the aging step entirely (legacy behavior)." + (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29))))) + (should (= 0 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-is-idempotent () + "Boundary: a second run finds nothing new to age; the todo file is stable." + (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1))) + (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2)))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :to-file))))) + +(ert-deftest tc-age-check-mode-previews-without-writing () + "Boundary: --check reports the aged count but writes neither file." + (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t)))) + (should (= 3 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-straggler-moves-through-to-archive () + "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run." + (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29)))) + (open (tc-test--section (plist-get out :result) "Age Open Work")) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should-not (tc-test--has open "old straggler")) + (should-not (tc-test--has resolved "old straggler")) + (should (tc-test--has arch "old straggler")) + (should (tc-test--has arch "straggler body")) + (should (tc-test--has resolved "recent stays")) + (should (= 1 (plist-get out :archived))) + (should (= 1 (plist-get out :to-file))))) + +(ert-deftest tc-age-append-preserves-existing-archive () + "Error/edge: appending to a populated archive keeps prior entries and one scaffold." + (let* ((adir (make-temp-file "tc-arch-" t)) + (afile (expand-file-name "task-archive.org" adir))) + (unwind-protect + (progn + (tc--append-subtrees-to-archive-file afile (list "** DONE one\n")) + (tc--append-subtrees-to-archive-file afile (list "** DONE two\n")) + (let ((content (with-temp-buffer (insert-file-contents afile) + (buffer-string))) + (n 0) (start 0)) + (should (tc-test--has content "** DONE one")) + (should (tc-test--has content "** DONE two")) + (should (tc-test--before-p content "** DONE one" "** DONE two")) + (while (string-match "\\* Resolved (archived)" content start) + (setq n (1+ n) start (match-end 0))) + (should (= 1 n)))) + (delete-directory adir t)))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done aging: the archive follows the todo file's gitignore status + +(defun tc-test--age-in-git-repo (gitignore-todo) + "Init a temp git repo, write todo.org with an old Resolved entry, optionally +gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path +(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final +.gitignore contents or nil), :archive-ignored (whether git ignores the archive), +:archive-exists." + (let* ((root (make-temp-file "tc-git-" t)) + (todo (expand-file-name "todo.org" root)) + (archive (expand-file-name "archive/task-archive.org" root)) + (gi (expand-file-name ".gitignore" root))) + (unwind-protect + (let ((default-directory root)) + (call-process "git" nil nil nil "init" "-q") + (with-temp-file todo (insert tc-test--age-resolved)) + (when gitignore-todo (with-temp-file gi (insert "/todo.org\n"))) + (tc-test--reset nil) + (setq tc-archive-retain-days 7 + tc-archive-reference-date '(2026 6 29) + tc-archive-file nil) ; default path, beside the todo file + (tc-process-file todo) + (tc-test--drop-buffer todo) + (list :gitignore (and (file-readable-p gi) + (with-temp-buffer (insert-file-contents gi) + (buffer-string))) + :archive-ignored + (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) + :archive-exists (file-readable-p archive))) + (delete-directory root t)))) + +(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () + "When the todo file is gitignored, the aged-out archive is added to .gitignore +so it inherits the same privacy." + (let ((out (tc-test--age-in-git-repo t))) + (should (plist-get out :archive-exists)) + (should (string-match-p "task-archive" (or (plist-get out :gitignore) ""))) + (should (plist-get out :archive-ignored)))) + +(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked () + "When the todo file is tracked, the archive is not gitignored — no .gitignore +entry is added for it." + (let ((out (tc-test--age-in-git-repo nil))) + (should (plist-get out :archive-exists)) + (should-not (plist-get out :archive-ignored)) + (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) ""))))) + +;;; --------------------------------------------------------------------------- ;;; Realistic synthetic sample (committed under fixtures/) (defun tc-test--sample-file () |
