diff --git a/HISTORY.org b/HISTORY.org index 8baecfa1..784d09ef 100644 --- a/HISTORY.org +++ b/HISTORY.org @@ -2,6 +2,7 @@ * Release history ** Main branch change +- Feat: Integrate with magit worktree feature - Feat: Modernize PR review tool ** 1.55 diff --git a/ai-code-git.el b/ai-code-git.el index e56a8f0e..8eebc768 100644 --- a/ai-code-git.el +++ b/ai-code-git.el @@ -27,6 +27,12 @@ Candidate values: :type 'string :group 'ai-code) +(defcustom ai-code-git-worktree-root + (expand-file-name "ai-code-worktrees" user-emacs-directory) + "Directory used to host centralized Git worktrees for all repositories." + :type 'directory + :group 'ai-code) + (declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt-text)) (declare-function ai-code--ensure-files-directory "ai-code-prompt-mode" ()) (declare-function ai-code--git-root "ai-code-file" (&optional dir)) @@ -755,6 +761,41 @@ buffer from which this command was invoked, instead of visiting the file." (insert "@" choice))) (find-file (expand-file-name choice base-dir)))))))) +(defun ai-code--git-worktree-repo-dir (git-root) + "Return centralized worktree directory for repository at GIT-ROOT." + (let ((repo-name (file-name-nondirectory (directory-file-name git-root)))) + (expand-file-name repo-name ai-code-git-worktree-root))) + +;;;###autoload +(defun ai-code-git-worktree-branch (branch start-point) + "Create BRANCH and check it out in a new centralized worktree. +The worktree path is +`ai-code-git-worktree-root/REPO-NAME/BRANCH'." + (interactive + (magit-branch-read-args "Create and checkout branch")) + (let* ((git-root (ai-code--validate-git-repository)) + (repo-worktree-dir (ai-code--git-worktree-repo-dir git-root)) + (path (expand-file-name branch repo-worktree-dir)) + (parent-dir (file-name-directory path))) + (unless (file-directory-p repo-worktree-dir) + (make-directory repo-worktree-dir t)) + (when (and parent-dir + (not (file-directory-p parent-dir))) + (make-directory parent-dir t)) + (when (zerop (magit-call-git "worktree" "add" "-b" branch + (file-truename path) start-point)) + (magit-diff-visit-directory path)))) + +;;;###autoload +(defun ai-code-git-worktree-action (&optional prefix) + "Dispatch worktree action by PREFIX. +Without PREFIX, call `ai-code-git-worktree-branch'. +With PREFIX (for example C-u), call `magit-worktree-status'." + (interactive "P") + (if prefix + (call-interactively #'magit-worktree-status) + (call-interactively #'ai-code-git-worktree-branch))) + (provide 'ai-code-git) ;;; ai-code-git.el ends here diff --git a/ai-code.el b/ai-code.el index d1956784..2b45fd38 100644 --- a/ai-code.el +++ b/ai-code.el @@ -445,6 +445,7 @@ Shows the current backend label to the right." ("" "Send command (C-u: context)" ai-code-send-command) ("@" "Context (add/show/clear)" ai-code-context-action) ("C" "Create file or dir with AI" ai-code-create-file-or-dir) + ("w" "New worktree branch (C-u: status)" ai-code-git-worktree-action) ] ["AI Agile Development" diff --git a/test/test_ai-code-git.el b/test/test_ai-code-git.el index bc051f8c..e0fcb5b0 100644 --- a/test/test_ai-code-git.el +++ b/test/test_ai-code-git.el @@ -238,6 +238,63 @@ When .gitignore is missing some entries, they should be added." "https://github.com/acme/demo/pull/999"))) (should (string-match-p "Review this pull request\\." prompt)))) +(ert-deftest ai-code-test-git-worktree-branch-creates-repo-directory-and-adds-worktree () + "Create repo worktree directory and invoke git worktree add with expected path." + (let* ((temp-worktree-root (make-temp-file "ai-code-worktree-root-" t)) + (ai-code-git-worktree-root temp-worktree-root) + (git-root "/tmp/sample-repo/") + (branch "feature/new-branch") + (start-point "main") + (repo-dir (expand-file-name "sample-repo" temp-worktree-root)) + (worktree-path (expand-file-name branch repo-dir)) + (worktree-parent-dir (file-name-directory worktree-path)) + captured-git-args + captured-visited-path) + (unwind-protect + (cl-letf (((symbol-function 'ai-code--validate-git-repository) + (lambda () git-root)) + ((symbol-function 'magit-run-git) + (lambda (&rest _args) + (ert-fail "`magit-run-git' should not be used for worktree add status check"))) + ((symbol-function 'magit-call-git) + (lambda (&rest args) + (setq captured-git-args args) + 0)) + ((symbol-function 'magit-diff-visit-directory) + (lambda (path) + (setq captured-visited-path path)))) + (should-not (file-directory-p repo-dir)) + (ai-code-git-worktree-branch branch start-point) + (should (file-directory-p repo-dir)) + (should (file-directory-p worktree-parent-dir)) + (should (equal captured-git-args + (list "worktree" + "add" + "-b" + branch + (file-truename worktree-path) + start-point))) + (should (equal captured-visited-path worktree-path))) + (delete-directory temp-worktree-root t)))) + +(ert-deftest ai-code-test-git-worktree-action-without-prefix-calls-worktree-branch () + "Without prefix arg, dispatch to `ai-code-git-worktree-branch'." + (let (captured-fn) + (cl-letf (((symbol-function 'call-interactively) + (lambda (fn &optional _record-flag _keys) + (setq captured-fn fn)))) + (ai-code-git-worktree-action nil) + (should (eq captured-fn #'ai-code-git-worktree-branch))))) + +(ert-deftest ai-code-test-git-worktree-action-with-prefix-calls-magit-worktree-status () + "With prefix arg, dispatch to `magit-worktree-status'." + (let (captured-fn) + (cl-letf (((symbol-function 'call-interactively) + (lambda (fn &optional _record-flag _keys) + (setq captured-fn fn)))) + (ai-code-git-worktree-action '(4)) + (should (eq captured-fn #'magit-worktree-status))))) + (defun ai-code-test--run-pull-or-review-diff-file (choice pr-url &optional review-mode-choice) "Run `ai-code-pull-or-review-diff-file' with CHOICE and optional PR-URL. REVIEW-MODE-CHOICE is used for review mode selection when prompted.