diff options
Diffstat (limited to 'modules/reconcile-open-repos.el')
| -rw-r--r-- | modules/reconcile-open-repos.el | 200 |
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) |
