summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-10 02:44:10 -0500
committerCraig Jennings <c@cjennings.net>2026-05-10 02:44:10 -0500
commit5a46d415df75b8b0168e2cf48b30fe463c01a77c (patch)
tree72c1f8d75b2fb4a020a46f4d3b97778ca65fb331 /modules
parent2afb005af6272b1b4229b377db0423dca045732f (diff)
downloaddotemacs-5a46d415df75b8b0168e2cf48b30fe463c01a77c.tar.gz
dotemacs-5a46d415df75b8b0168e2cf48b30fe463c01a77c.zip
Make repo reconciliation review-first
Stop automatically stashing, pulling, and popping dirty repos during reconciliation. Clean repos still pull, dirty repos open Magit for review, and results now include structured statuses, skip reasons, pruning, and a summary.
Diffstat (limited to 'modules')
-rw-r--r--modules/reconcile-open-repos.el200
1 files changed, 147 insertions, 53 deletions
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
index 87c16a31..20a324b6 100644
--- a/modules/reconcile-open-repos.el
+++ b/modules/reconcile-open-repos.el
@@ -3,14 +3,12 @@
;;
;;; Commentary:
;;
-;; Git repository reconciliation workflow for multiple projects. Ensures all git
-;; repositories in your projects/ and code/ directories are synchronized with
-;; remotes and have no uncommitted work at the start and end of work sessions.
-;; The workflow iterates through all git repositories in projects-dir and
-;; code-dir, skips local-only repos and http/https remotes (reference clones),
-;; silently pulls latest changes for clean repos, and for dirty repos stashes
-;; changes, pulls, pops stash, and opens Magit for review. Also checks org-dir
-;; and user-emacs-directory individually.
+;; Git repository reconciliation workflow for multiple projects. The workflow
+;; iterates through all git repositories in projects-dir and code-dir, skips
+;; local-only repos and remotes matching `cj/reconcile-skipped-remote-regexp',
+;; pulls latest changes for clean repos, and opens Magit for dirty repos without
+;; stashing, rebasing, or popping work automatically. Also checks org-dir and
+;; user-emacs-directory individually.
;;
;; Main function: cj/check-for-open-work (bound to M-P)
;;
@@ -20,6 +18,9 @@
;;; Code:
+(require 'cl-lib)
+(require 'subr-x)
+
;; Forward declarations for variables defined in init.el
(eval-when-compile
(defvar projects-dir)
@@ -29,91 +30,184 @@
;; Forward declaration for magit
(declare-function magit-status "magit" (&optional directory cache))
+(defcustom cj/reconcile-skipped-remote-regexp "^https?://"
+ "Regexp matching remote URLs that should be skipped by reconciliation.
+This defaults to HTTP/HTTPS remotes because this setup treats those as
+reference clones rather than active work repositories."
+ :type 'regexp
+ :group 'cj)
+
+(defcustom cj/reconcile-pruned-directory-names
+ '(".git" ".hg" ".svn"
+ "node_modules" ".venv" "venv" "__pycache__"
+ "target" "build" "dist" ".next" ".cache" "vendor")
+ "Directory basenames not descended while discovering git repositories."
+ :type '(repeat string)
+ :group 'cj)
+
+(defvar cj/reconcile-results nil
+ "Most recent list of repository reconciliation result plists.")
+
+;; ------------------------------- Git Process --------------------------------
+
+(defun cj/reconcile--git (directory &rest args)
+ "Run git in DIRECTORY with ARGS.
+Return a plist with :exit and :output. Git is invoked through
+`process-file' with an argv list, not through a shell."
+ (let ((default-directory (file-name-as-directory directory)))
+ (with-temp-buffer
+ (let ((exit-code (apply #'process-file "git" nil (list t t) nil args)))
+ (list :exit exit-code
+ :output (buffer-string)
+ :args args)))))
+
+(defun cj/reconcile--git-output (directory &rest args)
+ "Run git in DIRECTORY with ARGS and return trimmed output on success."
+ (let ((result (apply #'cj/reconcile--git directory args)))
+ (when (zerop (plist-get result :exit))
+ (string-trim (plist-get result :output)))))
+
;; ------------------------------ Skip Predicate -------------------------------
+(defun cj/reconcile--skip-reason (directory)
+ "Return a skip reason symbol for DIRECTORY, or nil if it should be processed."
+ (cond
+ ((not (file-directory-p (expand-file-name ".git" directory)))
+ 'not-a-git-repo)
+ (t
+ (let ((remote-url (cj/reconcile--git-output
+ directory "config" "--get" "remote.origin.url")))
+ (cond
+ ((or (null remote-url) (string-empty-p remote-url)) 'no-remote)
+ ((and cj/reconcile-skipped-remote-regexp
+ (string-match-p cj/reconcile-skipped-remote-regexp remote-url))
+ 'skipped-remote)
+ (t nil))))))
+
(defun cj/reconcile--should-skip-p (directory)
"Return non-nil if DIRECTORY should be skipped during reconciliation.
-Skips directories without .git, without a remote, or with http/https remotes
-\(reference clones)."
- (let ((default-directory directory))
- (or (not (file-directory-p (expand-file-name ".git" directory)))
- (let ((remote-url (string-trim (shell-command-to-string
- "git config --get remote.origin.url"))))
- (or (string-empty-p remote-url)
- (string-match-p "^\\(http\\|https\\)://" remote-url))))))
+Skips directories without .git, without a remote, or with remotes matching
+`cj/reconcile-skipped-remote-regexp'."
+ (and (cj/reconcile--skip-reason directory) t))
;; -------------------------------- Pull Clean --------------------------------
(defun cj/reconcile--pull-clean (directory)
"Pull latest changes for clean git repo at DIRECTORY."
- (let* ((default-directory directory)
- (pull-result (shell-command "git pull --rebase --quiet")))
- (unless (= pull-result 0)
- (message "Warning: git pull failed for %s (exit code: %d)" directory pull-result))))
+ (let ((result (cj/reconcile--git directory "pull" "--rebase" "--quiet")))
+ (if (zerop (plist-get result :exit))
+ (list :directory directory :status 'pulled :output (plist-get result :output))
+ (message "Warning: git pull failed for %s (exit code: %d)"
+ directory
+ (plist-get result :exit))
+ (list :directory directory
+ :status 'pull-failed
+ :exit (plist-get result :exit)
+ :output (plist-get result :output)))))
;; -------------------------------- Pull Dirty --------------------------------
(defun cj/reconcile--pull-dirty (directory)
- "Stash, pull, pop stash, and open Magit for dirty repo at DIRECTORY."
- (let ((default-directory directory))
- (message "%s contains uncommitted work" directory)
- (let ((stash-result (shell-command "git stash --quiet")))
- (if (= stash-result 0)
- (let ((pull-result (shell-command "git pull --rebase --quiet")))
- (when (= pull-result 0)
- (let ((stash-pop-result (shell-command "git stash pop --quiet")))
- (unless (= stash-pop-result 0)
- (message "Warning: git stash pop failed for %s - opening Magit" directory))))
- (unless (= pull-result 0)
- (message "Warning: git pull failed for %s - opening Magit" directory)))
- (message "Warning: git stash failed for %s - opening Magit" directory)))
- (magit-status directory)))
+ "Open Magit for dirty repo at DIRECTORY without modifying worktree state."
+ (message "%s contains uncommitted work; opening Magit for review" directory)
+ (magit-status directory)
+ (list :directory directory :status 'needs-review))
+
+;; ------------------------------- Repo Status --------------------------------
+
+(defun cj/reconcile--dirty-p (directory)
+ "Return non-nil if git repo DIRECTORY has uncommitted work."
+ (let ((status (cj/reconcile--git directory "status" "--porcelain")))
+ (if (zerop (plist-get status :exit))
+ (not (string-empty-p (string-trim (plist-get status :output))))
+ (message "Warning: git status failed for %s (exit code: %d)"
+ directory
+ (plist-get status :exit))
+ 'status-failed)))
;; -------------------------- Reconcile Git Directory --------------------------
(defun cj/reconcile-git-directory (directory)
"Reconcile unopened work in a git project DIRECTORY.
-Skips local-only repos and http/https remotes. For clean repos, silently pulls
-latest changes. For dirty repos, stashes changes, pulls, pops stash, and opens
-Magit for review."
+Skips local-only repos and configured remote policies. For clean repos, pulls
+latest changes. For dirty repos, opens Magit for review without mutating the
+worktree."
(message "checking: %s" directory)
- (unless (cj/reconcile--should-skip-p directory)
- (let ((default-directory directory))
- (if (string-empty-p (shell-command-to-string "git status --porcelain"))
- (cj/reconcile--pull-clean directory)
- (cj/reconcile--pull-dirty directory)))))
+ (let ((skip-reason (cj/reconcile--skip-reason directory)))
+ (cond
+ (skip-reason
+ (message "Skipping %s: %s" directory skip-reason)
+ (list :directory directory :status 'skipped :reason skip-reason))
+ (t
+ (let ((dirty (cj/reconcile--dirty-p directory)))
+ (cond
+ ((eq dirty 'status-failed)
+ (list :directory directory :status 'status-failed))
+ (dirty
+ (cj/reconcile--pull-dirty directory))
+ (t
+ (cj/reconcile--pull-clean directory))))))))
;; ---------------------------- Check For Open Work ----------------------------
-(defun cj/find-git-repos (directory)
+(defun cj/reconcile--pruned-directory-p (directory)
+ "Return non-nil if DIRECTORY should be pruned during repo discovery."
+ (member (file-name-nondirectory (directory-file-name directory))
+ cj/reconcile-pruned-directory-names))
+
+(defun cj/find-git-repos (directory &optional include-nested)
"Recursively find all git repositories under DIRECTORY.
-Returns a list of directory paths that contain a .git subdirectory."
+Returns a list of directory paths that contain a .git subdirectory.
+Prunes generated/heavy directories. Once a repository root is found, do not
+descend into it unless INCLUDE-NESTED is non-nil."
(let (repos)
- (dolist (child (directory-files directory t "^[^.]+$" 'nosort))
- (when (file-directory-p child)
- (when (file-directory-p (expand-file-name ".git" child))
- (push child repos))
- (setq repos (nconc repos (cj/find-git-repos child)))))
+ (when (file-directory-p directory)
+ (dolist (child (directory-files directory t "^[^.]+$" 'nosort))
+ (when (and (file-directory-p child)
+ (not (cj/reconcile--pruned-directory-p child)))
+ (if (file-directory-p (expand-file-name ".git" child))
+ (progn
+ (push child repos)
+ (when include-nested
+ (setq repos (nconc repos (cj/find-git-repos child include-nested)))))
+ (setq repos (nconc repos (cj/find-git-repos child include-nested)))))))
repos))
+(defun cj/reconcile--summary-message (results)
+ "Return a concise summary string for reconciliation RESULTS."
+ (let ((pulled 0)
+ (review 0)
+ (skipped 0)
+ (failed 0))
+ (dolist (result results)
+ (pcase (plist-get result :status)
+ ('pulled (cl-incf pulled))
+ ('needs-review (cl-incf review))
+ ('skipped (cl-incf skipped))
+ ((or 'pull-failed 'status-failed) (cl-incf failed))))
+ (format "Complete. Repositories checked: %d, pulled: %d, needs review: %d, skipped: %d, failed: %d"
+ (length results) pulled review skipped failed)))
+
(defun cj/check-for-open-work ()
"Check all project directories for open work."
(interactive)
;; these are constants defined in init.el
;; recursively find and check all git repos under these directories
+ (setq cj/reconcile-results nil)
(dolist (base-dir (list projects-dir code-dir))
(when (and base-dir (file-directory-p base-dir))
(dolist (repo (cj/find-git-repos base-dir))
- (cj/reconcile-git-directory repo))))
+ (push (cj/reconcile-git-directory repo) cj/reconcile-results))))
;; check these directories individually
(when (and (boundp 'org-dir) org-dir (file-directory-p org-dir))
- (cj/reconcile-git-directory org-dir))
+ (push (cj/reconcile-git-directory org-dir) cj/reconcile-results))
(when (and (boundp 'user-emacs-directory) user-emacs-directory (file-directory-p user-emacs-directory))
- (cj/reconcile-git-directory user-emacs-directory))
+ (push (cj/reconcile-git-directory user-emacs-directory) cj/reconcile-results))
;; communicate when finished.
- (message "Complete. All repositories checked and updated"))
+ (setq cj/reconcile-results (nreverse (delq nil cj/reconcile-results)))
+ (message "%s" (cj/reconcile--summary-message cj/reconcile-results)))
(keymap-global-set "M-P" #'cj/check-for-open-work)