summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/reconcile-open-repos.el84
-rw-r--r--tests/test-reconcile--find-git-repos.el77
-rw-r--r--tests/test-reconcile--git-directory.el94
-rw-r--r--tests/test-reconcile--pull-clean.el60
-rw-r--r--tests/test-reconcile--pull-dirty.el112
-rw-r--r--tests/test-reconcile--should-skip-p.el101
-rw-r--r--tests/testutil-reconcile-open-repos.el57
7 files changed, 549 insertions, 36 deletions
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
index 0cbe6c53..a7236754 100644
--- a/modules/reconcile-open-repos.el
+++ b/modules/reconcile-open-repos.el
@@ -29,6 +29,46 @@
;; Forward declaration for magit
(declare-function magit-status "magit" (&optional directory cache))
+;; ------------------------------ Skip Predicate -------------------------------
+
+(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))))))
+
+;; -------------------------------- 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))))
+
+;; -------------------------------- 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)))
+
;; -------------------------- Reconcile Git Directory --------------------------
(defun cj/reconcile-git-directory (directory)
@@ -37,39 +77,11 @@ 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."
(message "checking: %s" directory)
- (let ((default-directory directory))
- ;; Check for the presence of the .git directory
- (if (file-directory-p (expand-file-name ".git" directory))
- (progn
- (let ((remote-url (string-trim (shell-command-to-string "git config --get remote.origin.url"))))
-
- ;; skip local git repos, or remote URLs that are http or https,
- ;; these are typically cloned for reference only
- (unless (or (string-empty-p remote-url)
- (string-match-p "^\\(http\\|https\\)://" remote-url))
-
- ;; if git directory is clean, pulling generates no errors
- (if (string-empty-p (shell-command-to-string "git status --porcelain"))
- (progn
- (let ((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))))
-
- ;; if directory not clean, pull latest changes and display Magit for manual intervention
- (progn
- (message "%s contains uncommitted work" directory)
- (let ((stash-result (shell-command "git stash --quiet")))
- (if (= stash-result 0)
- (progn
- (let ((pull-result (shell-command "git pull --rebase --quiet")))
- (if (= 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)))
- (message "Warning: git pull failed for %s - opening Magit" directory)))
- (magit-status directory))
- (message "Warning: git stash failed for %s - opening Magit" directory)
- (magit-status 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)))))
;; ---------------------------- Check For Open Work ----------------------------
@@ -79,9 +91,9 @@ Returns a list of directory paths that contain a .git subdirectory."
(let (repos)
(dolist (child (directory-files directory t "^[^.]+$" 'nosort))
(when (file-directory-p child)
- (if (file-directory-p (expand-file-name ".git" child))
- (push child repos)
- (setq repos (nconc repos (cj/find-git-repos child))))))
+ (when (file-directory-p (expand-file-name ".git" child))
+ (push child repos))
+ (setq repos (nconc repos (cj/find-git-repos child)))))
repos))
(defun cj/check-for-open-work ()
diff --git a/tests/test-reconcile--find-git-repos.el b/tests/test-reconcile--find-git-repos.el
new file mode 100644
index 00000000..25987818
--- /dev/null
+++ b/tests/test-reconcile--find-git-repos.el
@@ -0,0 +1,77 @@
+;;; test-reconcile--find-git-repos.el --- Tests for cj/find-git-repos -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for recursive git repository discovery in cj/find-git-repos.
+;; Uses real temporary directory trees with fake .git directories.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-reconcile-open-repos)
+(require 'reconcile-open-repos)
+
+;;; Normal Cases
+
+(ert-deftest test-find-git-repos-normal-flat-repos ()
+ "Finds multiple git repos at the same level."
+ (reconcile-test-with-temp-dirs
+ ("repo-a/.git/" "repo-b/.git/" "repo-c/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 3)))))
+
+(ert-deftest test-find-git-repos-normal-nested-repo ()
+ "Finds a repo nested inside a non-repo directory."
+ (reconcile-test-with-temp-dirs
+ ("parent/child/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 1))
+ (should (string-suffix-p "child" (car repos))))))
+
+(ert-deftest test-find-git-repos-normal-repo-with-nested-subrepo ()
+ "Finds both a parent repo and a sub-repo inside it."
+ (reconcile-test-with-temp-dirs
+ ("deepsat/.git/" "deepsat/frontend/.git/" "deepsat/backend/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 3)))))
+
+(ert-deftest test-find-git-repos-normal-mixed-repos-and-dirs ()
+ "Finds repos while skipping plain directories."
+ (reconcile-test-with-temp-dirs
+ ("repo-a/.git/" "not-a-repo/readme.txt" "repo-b/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 2)))))
+
+(ert-deftest test-find-git-repos-normal-deeply-nested ()
+ "Finds a repo several levels deep."
+ (reconcile-test-with-temp-dirs
+ ("a/b/c/deep-repo/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 1))
+ (should (string-suffix-p "deep-repo" (car repos))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-find-git-repos-boundary-empty-directory ()
+ "Returns empty list for directory with no children."
+ (reconcile-test-with-temp-dirs
+ ()
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 0)))))
+
+(ert-deftest test-find-git-repos-boundary-no-git-repos ()
+ "Returns empty list when no directories contain .git."
+ (reconcile-test-with-temp-dirs
+ ("dir-a/file.txt" "dir-b/file.txt")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 0)))))
+
+(ert-deftest test-find-git-repos-boundary-hidden-dirs-skipped ()
+ "Skips hidden directories (starting with dot) per the regex filter."
+ (reconcile-test-with-temp-dirs
+ (".hidden-repo/.git/" "visible-repo/.git/")
+ (let ((repos (cj/find-git-repos test-root)))
+ (should (= (length repos) 1))
+ (should (string-suffix-p "visible-repo" (car repos))))))
+
+(provide 'test-reconcile--find-git-repos)
+;;; test-reconcile--find-git-repos.el ends here
diff --git a/tests/test-reconcile--git-directory.el b/tests/test-reconcile--git-directory.el
new file mode 100644
index 00000000..ab4a6323
--- /dev/null
+++ b/tests/test-reconcile--git-directory.el
@@ -0,0 +1,94 @@
+;;; test-reconcile--git-directory.el --- Tests for cj/reconcile-git-directory -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the top-level reconcile function that dispatches to skip/clean/dirty.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-reconcile-open-repos)
+(require 'reconcile-open-repos)
+
+;;; Normal Cases
+
+(ert-deftest test-reconcile-git-directory-normal-clean-repo-pulls ()
+ "Clean SSH repo calls pull-clean, not pull-dirty."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (clean-called nil)
+ (dirty-called nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (cond ((string-match-p "remote.origin.url" cmd) "git@host:repo.git")
+ ((string-match-p "status --porcelain" cmd) "")
+ (t "")))
+ (cl-letf (((symbol-function 'cj/reconcile--pull-clean)
+ (lambda (_dir) (setq clean-called t)))
+ ((symbol-function 'cj/reconcile--pull-dirty)
+ (lambda (_dir) (setq dirty-called t)))
+ ((symbol-function 'message) (lambda (_fmt &rest _args))))
+ (cj/reconcile-git-directory dir)))
+ (should clean-called)
+ (should-not dirty-called))))
+
+(ert-deftest test-reconcile-git-directory-normal-dirty-repo-stashes ()
+ "Dirty SSH repo calls pull-dirty, not pull-clean."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (clean-called nil)
+ (dirty-called nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (cond ((string-match-p "remote.origin.url" cmd) "git@host:repo.git")
+ ((string-match-p "status --porcelain" cmd) " M file.el\n")
+ (t "")))
+ (cl-letf (((symbol-function 'cj/reconcile--pull-clean)
+ (lambda (_dir) (setq clean-called t)))
+ ((symbol-function 'cj/reconcile--pull-dirty)
+ (lambda (_dir) (setq dirty-called t)))
+ ((symbol-function 'message) (lambda (_fmt &rest _args))))
+ (cj/reconcile-git-directory dir)))
+ (should-not clean-called)
+ (should dirty-called))))
+
+(ert-deftest test-reconcile-git-directory-normal-skipped-repo-no-calls ()
+ "HTTP repo is skipped entirely — neither pull-clean nor pull-dirty called."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (clean-called nil)
+ (dirty-called nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "https://github.com/user/repo.git"
+ ""))
+ (cl-letf (((symbol-function 'cj/reconcile--pull-clean)
+ (lambda (_dir) (setq clean-called t)))
+ ((symbol-function 'cj/reconcile--pull-dirty)
+ (lambda (_dir) (setq dirty-called t)))
+ ((symbol-function 'message) (lambda (_fmt &rest _args))))
+ (cj/reconcile-git-directory dir)))
+ (should-not clean-called)
+ (should-not dirty-called))))
+
+;;; Boundary Cases
+
+(ert-deftest test-reconcile-git-directory-boundary-emits-checking-message ()
+ "Always emits 'checking: <dir>' message, even for skipped repos."
+ (reconcile-test-with-temp-dirs
+ ("repo/readme.txt")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile-git-directory dir))
+ (should (cl-some (lambda (m) (string-match-p "checking:" m)) messages)))))
+
+(provide 'test-reconcile--git-directory)
+;;; test-reconcile--git-directory.el ends here
diff --git a/tests/test-reconcile--pull-clean.el b/tests/test-reconcile--pull-clean.el
new file mode 100644
index 00000000..a10c6f1e
--- /dev/null
+++ b/tests/test-reconcile--pull-clean.el
@@ -0,0 +1,60 @@
+;;; test-reconcile--pull-clean.el --- Tests for cj/reconcile--pull-clean -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for pulling latest changes on a clean git repository.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-reconcile-open-repos)
+(require 'reconcile-open-repos)
+
+;;; Normal Cases
+
+(ert-deftest test-pull-clean-normal-success ()
+ "Successful pull produces no warning message."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-clean dir)))
+ (should-not (cl-some (lambda (m) (string-match-p "Warning" m)) messages)))))
+
+(ert-deftest test-pull-clean-normal-failure-warns ()
+ "Failed pull produces a warning message with directory and exit code."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 1)
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-clean dir)))
+ (should (cl-some (lambda (m) (string-match-p "Warning.*git pull failed" m)) messages))
+ (should (cl-some (lambda (m) (string-match-p "exit code: 1" m)) messages)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-pull-clean-boundary-nonzero-exit-128 ()
+ "Exit code 128 (common git error) is reported in warning."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 128)
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-clean dir)))
+ (should (cl-some (lambda (m) (string-match-p "exit code: 128" m)) messages)))))
+
+(provide 'test-reconcile--pull-clean)
+;;; test-reconcile--pull-clean.el ends here
diff --git a/tests/test-reconcile--pull-dirty.el b/tests/test-reconcile--pull-dirty.el
new file mode 100644
index 00000000..2ba1f5d1
--- /dev/null
+++ b/tests/test-reconcile--pull-dirty.el
@@ -0,0 +1,112 @@
+;;; test-reconcile--pull-dirty.el --- Tests for cj/reconcile--pull-dirty -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the dirty-repo reconciliation: stash, pull, pop, magit.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-reconcile-open-repos)
+(require 'reconcile-open-repos)
+
+;;; Normal Cases
+
+(ert-deftest test-pull-dirty-normal-stash-pull-pop-success ()
+ "When stash, pull, and pop all succeed, magit is still opened."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (_cmd) "")
+ (cj/reconcile--pull-dirty dir))
+ (should (member dir reconcile-test-magit-calls))))))
+
+(ert-deftest test-pull-dirty-normal-stash-fails-opens-magit ()
+ "When stash fails, magit is opened and warning emitted."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (cmd)
+ (if (string-match-p "stash --quiet\\'" cmd) 1 0))
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-dirty dir)))
+ (should (member dir reconcile-test-magit-calls))
+ (should (cl-some (lambda (m) (string-match-p "stash failed" m)) messages))))))
+
+(ert-deftest test-pull-dirty-normal-pull-fails-warns ()
+ "When stash succeeds but pull fails, warning mentions pull failure."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (cmd)
+ (cond ((string-match-p "stash --quiet\\'" cmd) 0)
+ ((string-match-p "pull" cmd) 1)
+ (t 0)))
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-dirty dir)))
+ (should (cl-some (lambda (m) (string-match-p "git pull failed" m)) messages))))))
+
+(ert-deftest test-pull-dirty-normal-stash-pop-fails-warns ()
+ "When stash and pull succeed but pop fails, warning mentions stash pop."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (cmd)
+ (cond ((string-match-p "stash pop" cmd) 1)
+ ((string-match-p "stash" cmd) 0)
+ (t 0)))
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-dirty dir)))
+ (should (cl-some (lambda (m) (string-match-p "stash pop failed" m)) messages))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-pull-dirty-boundary-always-opens-magit ()
+ "Magit is opened regardless of whether pull succeeds or fails."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ ;; Test with pull failure
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (cmd)
+ (if (string-match-p "pull" cmd) 1 0))
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message) (lambda (_fmt &rest _args))))
+ (cj/reconcile--pull-dirty dir)))
+ (should (member dir reconcile-test-magit-calls))))))
+
+(ert-deftest test-pull-dirty-boundary-uncommitted-work-message ()
+ "Always emits 'contains uncommitted work' message."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root))
+ (messages nil))
+ (reconcile-test-with-magit-mock
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (_cmd) "")
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
+ (cj/reconcile--pull-dirty dir)))
+ (should (cl-some (lambda (m) (string-match-p "uncommitted work" m)) messages))))))
+
+(provide 'test-reconcile--pull-dirty)
+;;; test-reconcile--pull-dirty.el ends here
diff --git a/tests/test-reconcile--should-skip-p.el b/tests/test-reconcile--should-skip-p.el
new file mode 100644
index 00000000..3e9c0177
--- /dev/null
+++ b/tests/test-reconcile--should-skip-p.el
@@ -0,0 +1,101 @@
+;;; test-reconcile--should-skip-p.el --- Tests for cj/reconcile--should-skip-p -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the skip predicate that filters out non-git dirs, local-only repos,
+;; and http/https reference clones.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-reconcile-open-repos)
+(require 'reconcile-open-repos)
+
+;;; Normal Cases
+
+(ert-deftest test-should-skip-p-normal-ssh-remote-not-skipped ()
+ "SSH remote repo should NOT be skipped."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "git@github.com:user/repo.git"
+ ""))
+ (should-not (cj/reconcile--should-skip-p dir))))))
+
+(ert-deftest test-should-skip-p-normal-http-remote-skipped ()
+ "HTTP remote repo should be skipped (reference clone)."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "http://github.com/user/repo.git"
+ ""))
+ (should (cj/reconcile--should-skip-p dir))))))
+
+(ert-deftest test-should-skip-p-normal-https-remote-skipped ()
+ "HTTPS remote repo should be skipped (reference clone)."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "https://github.com/user/repo.git"
+ ""))
+ (should (cj/reconcile--should-skip-p dir))))))
+
+(ert-deftest test-should-skip-p-normal-no-remote-skipped ()
+ "Local-only repo (no remote) should be skipped."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd) "" ""))
+ (should (cj/reconcile--should-skip-p dir))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-should-skip-p-boundary-no-git-dir ()
+ "Directory without .git should be skipped."
+ (reconcile-test-with-temp-dirs
+ ("not-a-repo/readme.txt")
+ (let ((dir (expand-file-name "not-a-repo" test-root)))
+ (should (cj/reconcile--should-skip-p dir)))))
+
+(ert-deftest test-should-skip-p-boundary-scp-style-remote-not-skipped ()
+ "SCP-style remote (user@host:path) should NOT be skipped."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "user@myserver.com:repos/project.git"
+ ""))
+ (should-not (cj/reconcile--should-skip-p dir))))))
+
+(ert-deftest test-should-skip-p-boundary-ssh-protocol-url-not-skipped ()
+ "ssh:// protocol URL should NOT be skipped."
+ (reconcile-test-with-temp-dirs
+ ("repo/.git/")
+ (let ((dir (expand-file-name "repo" test-root)))
+ (reconcile-test-with-shell-mocks
+ (lambda (_cmd) 0)
+ (lambda (cmd)
+ (if (string-match-p "remote.origin.url" cmd)
+ "ssh://git@github.com/user/repo.git"
+ ""))
+ (should-not (cj/reconcile--should-skip-p dir))))))
+
+(provide 'test-reconcile--should-skip-p)
+;;; test-reconcile--should-skip-p.el ends here
diff --git a/tests/testutil-reconcile-open-repos.el b/tests/testutil-reconcile-open-repos.el
new file mode 100644
index 00000000..2d8614eb
--- /dev/null
+++ b/tests/testutil-reconcile-open-repos.el
@@ -0,0 +1,57 @@
+;;; testutil-reconcile-open-repos.el --- Test helpers for reconcile-open-repos -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Provides helper macros and functions for testing reconcile-open-repos.
+;; Creates temporary directory trees with fake .git dirs and mocks shell commands.
+
+;;; Code:
+
+(require 'cl-lib)
+
+(defmacro reconcile-test-with-temp-dirs (dir-spec &rest body)
+ "Create a temp directory tree per DIR-SPEC, bind `test-root', then run BODY.
+DIR-SPEC is a list of relative paths. Paths ending in / create directories;
+others create files. A path containing `.git/' creates the .git dir automatically.
+
+Example:
+ (reconcile-test-with-temp-dirs
+ (\"repo-a/.git/\" \"repo-b/subdir/\" \"not-a-repo/readme.txt\")
+ ...use test-root...)"
+ (declare (indent 1))
+ `(let ((test-root (make-temp-file "reconcile-test-" t)))
+ (unwind-protect
+ (progn
+ (dolist (path ',dir-spec)
+ (let ((full (expand-file-name path test-root)))
+ (if (string-suffix-p "/" path)
+ (make-directory full t)
+ (progn
+ (make-directory (file-name-directory full) t)
+ (write-region "" nil full)))))
+ ,@body)
+ (delete-directory test-root t))))
+
+(defmacro reconcile-test-with-shell-mocks (shell-cmd-fn shell-cmd-to-str-fn &rest body)
+ "Run BODY with `shell-command' and `shell-command-to-string' overridden.
+SHELL-CMD-FN receives (command) and returns an exit code integer.
+SHELL-CMD-TO-STR-FN receives (command) and returns a string."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'shell-command)
+ (lambda (cmd &rest _) (funcall ,shell-cmd-fn cmd)))
+ ((symbol-function 'shell-command-to-string)
+ (lambda (cmd) (funcall ,shell-cmd-to-str-fn cmd))))
+ ,@body))
+
+(defvar reconcile-test-magit-calls nil
+ "List of directories passed to magit-status during tests.")
+
+(defmacro reconcile-test-with-magit-mock (&rest body)
+ "Run BODY with `magit-status' mocked to record calls."
+ (declare (indent 0))
+ `(let ((reconcile-test-magit-calls nil))
+ (cl-letf (((symbol-function 'magit-status)
+ (lambda (dir &rest _) (push dir reconcile-test-magit-calls))))
+ ,@body)))
+
+(provide 'testutil-reconcile-open-repos)
+;;; testutil-reconcile-open-repos.el ends here