-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathai-code-git.el
More file actions
1074 lines (986 loc) · 54.1 KB
/
ai-code-git.el
File metadata and controls
1074 lines (986 loc) · 54.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; ai-code-git.el --- Git operations for AI Code -*- lexical-binding: t; -*-
;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0
;;; Commentary:
;; This file provides git operation functionality for the AI Code package.
;;; Code:
(require 'magit)
(require 'ai-code-input)
(require 'ai-code-prompt-mode)
(declare-function helm-gtags-create-tags "helm-gtags" (dir &optional label))
(declare-function magit-anything-modified-p "magit" ())
(declare-function magit-branch-p "magit" (branch))
(declare-function magit-branch-read-args "magit-branch" (prompt))
(declare-function magit-call-git "magit-git" (&rest args))
(declare-function magit-diff-visit-directory "magit-diff" (directory))
(declare-function magit-git-lines "magit-git" (&rest args))
(declare-function magit-git-output "magit-git" (&rest args))
(declare-function magit-git-string "magit-git" (&rest args))
(declare-function magit-rev-verify "magit-git" (rev))
(declare-function magit-run-git "magit-git" (&rest args))
(declare-function magit-worktree-status "magit-worktree" ())
(defcustom ai-code-init-project-gtags-label "pygments"
"Default label passed to Helm-Gtags when initializing a project.
Candidate values:
- \\='default
- \\='native
- \\='ctags
- \\='new-ctags
- \\='pygments"
:type 'string
:group 'ai-code)
(defcustom ai-code-default-review-source nil
"Default review source for pull request and issue analysis.
When set, `ai-code--pull-or-review-action-choice' returns this value
directly without prompting. Valid values are `github-mcp', `gh-cli',
or nil (prompt the user)."
:type '(choice (const :tag "Prompt user" nil)
(const :tag "GitHub MCP server" github-mcp)
(const :tag "gh CLI tool" gh-cli))
: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))
(defvar ai-code-files-dir-name)
(defvar ai-code-pr-title-history nil
"Minibuffer history for optional PR titles.")
(defun ai-code--git-ignored-repo-file-p (file root)
"Return non-nil when FILE should be ignored for repo candidates under ROOT."
(when (and file root)
(let ((ignore-dir (file-truename (expand-file-name ai-code-files-dir-name root)))
(truename (file-truename file)))
(string-prefix-p ignore-dir truename))))
;; DONE: add a customize variable, default value is nil. It will be used to indicate if we want to use GitHub MCP server or gh CLI tool by default. If it is setting, it will return github-mcp or gh-cli. If it's nil, keep the current code path.
(defun ai-code--pull-or-review-action-choice ()
"Prompt user for action in `ai-code-pull-or-review-diff-file'.
When `ai-code-default-review-source' is set, return it directly."
(or ai-code-default-review-source
(let* ((action-alist '(("Use GitHub MCP server" . github-mcp)
("Use gh CLI tool" . gh-cli)))
(choice (completing-read "Select review source: "
action-alist
nil t nil nil "Use GitHub MCP server")))
(alist-get choice action-alist nil nil #'string=))))
(defun ai-code--build-pr-review-init-prompt (review-source pr-url)
"Build PR review initial prompt for REVIEW-SOURCE with PR-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source)))
(format "Review pull request: %s
%s
Review Steps:
1. Requirement Fit: Verify the PR implementation against requirements.
2. Code Quality: Check code quality, security, and performance concerns.
3. Findings: For each issue include location, issue, fix suggestion, and priority.
Provide an overall assessment at the end."
pr-url source-instruction)))
(defun ai-code--build-pr-feedback-check-init-prompt (review-source pr-url)
"Build unresolved feedback check prompt for REVIEW-SOURCE with PR-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source)))
(format "Check unresolved feedback for pull request: %s
%s
Feedback Check Steps:
1. Find unresolved feedback or unresolved review comments in this PR.
2. For each unresolved feedback, explain whether it makes sense and why.
3. If a feedback does not make sense, explain why it may not be necessary.
4. No need to make code change. Provide analysis only."
pr-url source-instruction)))
(defun ai-code--build-issue-investigation-init-prompt (review-source issue-url)
"Build issue investigation prompt for REVIEW-SOURCE with ISSUE-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source 'investigate-issue)))
(format "Investigate issue: %s
%s
Issue Investigation Steps:
1. Understand the issue description, reproduction details, and expected behavior.
2. Analyze relevant code in this repository as context and identify likely root causes.
3. Provide concrete insights on how to fix it, including likely files or areas to change.
4. No need to make code change. Provide analysis only."
issue-url source-instruction)))
(defun ai-code--build-pr-description-init-prompt (review-source pr-url)
"Build PR description prompt for REVIEW-SOURCE with PR-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source)))
;; DONE: Update the prompt to make the description sound like it's written by the author/maintainer, and keep it concise.
(format "Prepare a pull request description for: %s
%s
PR Description Steps:
1. Add a summary of the problem and the approach taken in the PR.
2. Highlight the most important code changes and user-visible impact.
3. Add a testing section with relevant verification details.
4. Format the result as a concise PR description ready to share with reviewers,
written in the voice of the author or maintainer."
pr-url source-instruction)))
(defun ai-code--build-pr-ci-check-init-prompt (review-source pr-url)
"Build a CI check review prompt for REVIEW-SOURCE with PR-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source 'review-ci-checks)))
(format "Review GitHub CI checks for pull request: %s
%s
CI Checks Review Steps:
1. Review the GitHub CI checks for this pull request.
2. If there is a failing or error state, inspect the failing checks and relevant details.
3. Analyze the likely root cause of each failure.
4. No need to make code change. Provide analysis only."
pr-url source-instruction)))
(defun ai-code--build-resolve-merge-conflict-init-prompt (review-source pr-url)
"Build merge conflict resolution prompt for REVIEW-SOURCE with PR-URL."
(let ((source-instruction
(ai-code--pull-or-review-source-instruction review-source 'resolve-merge-conflict)))
(format "Resolve merge conflict for pull request: %s
%s
Merge Conflict Resolution Steps:
1. Identify the current working branch and the target branch from the PR.
2. Verify the current working branch matches the PR source branch.
3. Update the local target branch to the latest remote version.
4. Attempt to merge the target branch into the current working branch.
5. If there are merge conflicts, analyze each conflict and provide detailed instructions on how to resolve them, including which files to change and how.
6. Do not make code changes. Provide analysis and resolution suggestions only.
7. If there are no merge conflicts, report that the merge would succeed and return a success message."
pr-url source-instruction)))
(defun ai-code--pull-or-review-source-instruction (review-source &optional review-mode)
"Return source instruction string for REVIEW-SOURCE and REVIEW-MODE."
(pcase review-mode
('investigate-issue
(pcase review-source
('github-mcp
"Use GitHub MCP server to inspect the GitHub issue and relevant repository context.")
('gh-cli
"Use gh CLI tool to inspect the GitHub issue and relevant repository context.")
(_ "Investigate this GitHub issue using the repository as context.")))
('review-ci-checks
(pcase review-source
('github-mcp
"Use GitHub MCP server to fetch pull request details and CI checks.")
('gh-cli
"Use gh CLI tool to fetch pull request details and CI checks.")
(_ "Review the GitHub CI checks for this pull request.")))
('resolve-merge-conflict
(pcase review-source
('github-mcp
"Use GitHub MCP server to fetch pull request branch details and merge status.")
('gh-cli
"Use gh CLI tool to fetch pull request branch details and merge status.")
(_ "Resolve merge conflicts for this pull request.")))
(_
(pcase review-source
('github-mcp
"Use GitHub MCP server to fetch pull request details and review comments.")
('gh-cli
"Use gh CLI tool to fetch pull request details and review comments.")
(_ "Review this pull request.")))))
(defun ai-code--pull-or-review-url-prompt (review-mode)
"Return the URL prompt string for REVIEW-MODE."
(if (eq review-mode 'investigate-issue)
"GitHub issue URL: "
"Pull request URL: "))
(defun ai-code--pull-or-review-pr-with-source (review-source)
"Prompt for a mode and send a prompt for REVIEW-SOURCE to AI.
If the selected mode is `generate-diff-file', generate a diff file.
If `send-current-branch-pr', ask for the target branch.
Otherwise, ask for the relevant pull request or issue URL."
(let* ((review-mode (ai-code--pull-or-review-pr-mode-choice)))
(if (eq review-mode 'generate-diff-file)
(ai-code--magit-generate-feature-branch-diff-file)
(let* ((init-prompt
(if (eq review-mode 'send-current-branch-pr)
(progn
(unless (magit-toplevel)
(user-error "Not inside a Git repository"))
(let* ((current-branch (ai-code--require-current-branch))
(default-target-branch
(ai-code--default-pr-target-branch current-branch))
(target-branch
(ai-code-read-string "Target branch to merge into: "
default-target-branch))
(pr-title
(read-string
"PR title (optional, leave empty for AI to generate): "
nil
'ai-code-pr-title-history)))
(ai-code--build-send-current-branch-pr-init-prompt
review-source current-branch target-branch pr-title)))
(let* ((url-prompt (ai-code--pull-or-review-url-prompt review-mode))
(target-url (ai-code-read-string url-prompt)))
(ai-code--build-pr-init-prompt review-source target-url review-mode))))
(prompt-label (if (eq review-mode 'send-current-branch-pr)
"Enter PR creation prompt: "
"Enter review prompt: "))
(prompt (ai-code-read-string prompt-label init-prompt)))
(ai-code--insert-prompt prompt)))))
(defun ai-code--pull-or-review-pr-mode-choice ()
"Prompt user to choose analysis mode for a pull request or issue."
;; DONE: add a choice: send out PR for current branch. The feature will ask user the target branch to merge. By default, it should be parent branch of current branch. AI should send out PR with description. The description should looks like it's written by the author, and it should be short.
;; DONE: for send out PR feature, it should ask user about the PR title. If user does not provide one, AI should generate a concise title based on the code change
;; DONE: the generate diff file function should be moved here, in the tail as an option. It shouldn't be in ai-code--pull-or-review-action-choice function. and downstream process should be moved and not inside ai-code-pull-or-review-diff-file
;; DONE: add an option: resolve merge conflict. It accept a PR URL, which want to merge current working branch (you should verify) and target branch to merge to. AI should update local target branch to the latest, and try to merge the target branch into current working branch. If there is any merge conflict, AI should analyze the conflict and provide detailed instruction on how to resolve the conflict, including which files to change and how to change. Do not make code change, just provide suggestion on how to resolve conflicts. If there is no merge conflict, AI should just finish the merge and return a success message.
(let* ((review-mode-alist '(("Review the PR" . review-pr)
("Check unresolved feedback" . check-feedback)
("Prepare PR description" . prepare-pr-description)
("Send out PR for current branch" . send-current-branch-pr)
("Investigate issue" . investigate-issue)
("Review GitHub CI checks" . review-ci-checks)
("Resolve merge conflict" . resolve-merge-conflict)
("Generate diff file" . generate-diff-file)))
(review-mode (completing-read "Select analysis mode (PR or issue): "
review-mode-alist
nil t nil nil "Review the PR")))
(or (alist-get review-mode review-mode-alist nil nil #'string=)
'review-pr)))
(defun ai-code--build-pr-init-prompt (review-source target-url review-mode)
"Build initial prompt for REVIEW-SOURCE, TARGET-URL and REVIEW-MODE."
;; DONE: add an option here: review github CI checks. If there is error, look into them and figure out root cause. Only provide analysis, no need to change code.
(pcase review-mode
('investigate-issue
(ai-code--build-issue-investigation-init-prompt review-source target-url))
('check-feedback
(ai-code--build-pr-feedback-check-init-prompt review-source target-url))
('review-ci-checks
(ai-code--build-pr-ci-check-init-prompt review-source target-url))
('prepare-pr-description
(ai-code--build-pr-description-init-prompt review-source target-url))
('resolve-merge-conflict
(ai-code--build-resolve-merge-conflict-init-prompt review-source target-url))
(_
(ai-code--build-pr-review-init-prompt review-source target-url))))
(defun ai-code--require-current-branch ()
"Return the current branch name or signal a user error."
(or (magit-get-current-branch)
(user-error "Current branch is not available")))
(defun ai-code--normalize-branch-name (branch)
"Normalize BRANCH for display and user defaults."
(when branch
(replace-regexp-in-string
"\\`refs/heads/\\|\\`refs/remotes/[^/]+/\\|\\`origin/"
""
branch)))
(defun ai-code--remote-default-branch ()
"Return the normalized default branch for origin, or nil when unavailable."
(ai-code--normalize-branch-name
(ignore-errors
(magit-git-string "symbolic-ref" "--quiet" "--short" "refs/remotes/origin/HEAD"))))
(defun ai-code--default-pr-target-branch (current-branch)
"Return the default PR target branch for CURRENT-BRANCH."
(let* ((upstream-branch
(ignore-errors
(magit-git-string "rev-parse"
"--abbrev-ref"
"--symbolic-full-name"
"@{upstream}")))
(remote-default-branch
(ai-code--remote-default-branch))
(normalized-upstream
(ai-code--normalize-branch-name upstream-branch)))
(cond
((and normalized-upstream
(not (string-empty-p normalized-upstream))
(not (string= normalized-upstream current-branch)))
normalized-upstream)
((and remote-default-branch
(not (string-empty-p remote-default-branch))
(not (string= remote-default-branch current-branch)))
remote-default-branch)
((or (magit-branch-p "main") (magit-branch-p "origin/main"))
"main")
((or (magit-branch-p "master") (magit-branch-p "origin/master"))
"master")
(t nil))))
(defun ai-code--send-current-branch-pr-source-instruction (review-source)
"Return PR creation instructions for REVIEW-SOURCE."
(pcase review-source
('gh-cli
(concat
"Use GitHub CLI to create the pull request. "
"Run `gh pr create` with the current branch as the head branch, "
"target the requested base branch, and include the final title and body."))
('github-mcp
(concat
"Use GitHub MCP tools to create the pull request directly. "
"Do not fetch review comments before the PR exists; "
"create the PR first, then return the resulting PR URL."))
(_
(concat
"Create the pull request using the backend's PR creation capability. "
"Do not treat this as a PR review flow before the PR exists."))))
(defun ai-code--build-send-current-branch-pr-init-prompt
(review-source current-branch target-branch &optional pr-title)
"Build a PR creation prompt.
REVIEW-SOURCE, CURRENT-BRANCH, TARGET-BRANCH, and PR-TITLE
define the PR request."
(let ((source-instruction
(ai-code--send-current-branch-pr-source-instruction review-source))
(title-instruction
(if (string-empty-p (or pr-title ""))
"2. Generate a concise PR title based on the code change.\n"
(format "2. Use this PR title exactly: %s\n" pr-title))))
(format "Create a pull request from branch %s into %s.
%s
PR Creation Steps:
1. Inspect the current branch changes and open or send out a pull request into %s.
%s3. Write a concise PR description that sounds like it was written by the author, but do not make it too short.
4. Keep the description focused on the problem, the approach, and the most important verification, with enough detail for reviewers to understand the change quickly.
5. Aim for a compact but complete description, roughly a short summary plus 2 to 3 brief supporting paragraphs or bullet points.
6. Return the final PR URL, the exact PR title, and the exact description that were used."
current-branch
target-branch
source-instruction
target-branch
title-instruction)))
;;;###autoload
(defun ai-code-pull-or-review-diff-file ()
"Review a diff file with AI Code or choose a PR review workflow.
If current buffer is a .diff file, ask AI Code to review it.
Otherwise, prompt for a review source and analysis mode."
(interactive)
(if (and buffer-file-name (string-match-p "\\.diff$" buffer-file-name))
(let* ((file-name (file-name-nondirectory buffer-file-name))
(init-prompt (format "Code review for %s. Use relevant file in repository as context.
**Review Steps**:
1. **Requirement Fit** (Top Priority): Verify if the code change fulfills the requirement below. Identify gaps or missing implementations.
2. **Code Quality**: Check for quality, security, performance, and coding patterns.
3. **Issues Found**: For each issue: Location, Issue, Solution, Priority (High/Medium/Low)
Provide overall assessment.
**Requirement**: " file-name))
(prompt (ai-code-read-string "Enter review prompt (type requirement at end): " init-prompt)))
(ai-code--insert-prompt prompt))
;; For non-diff files, let user choose PR review via MCP/gh CLI.
(ai-code--pull-or-review-pr-with-source (ai-code--pull-or-review-action-choice))))
(defun ai-code--validate-git-repository ()
"Validate that current directory is in a git repository.
Return the git root directory if valid, otherwise signal an error."
(let ((git-root (ai-code--git-root)))
(if git-root
git-root
(user-error "Not in a git repository"))))
(defun ai-code--validate-buffer-file ()
"Validate that current buffer is visiting a file.
Signal an error if not visiting a file."
(unless buffer-file-name
(user-error "Buffer is not visiting a file")))
(defun ai-code--get-full-branch-ref (branch)
"Get full reference for BRANCH, handling remote branches properly.
Prefer remote branch (origin/BRANCH) if it exists.
Otherwise, use local branch or ref.
Git can diff remote branches directly without checking them out locally."
(cond
;; Check if it exists as a remote branch first, unless 'branch' already starts with 'origin/'
((and (not (string-prefix-p "origin/" branch)) (magit-branch-p (concat "origin/" branch)))
(concat "origin/" branch))
;; Then check if it's a valid local branch or ref (this will also handle cases like "origin/main" directly)
((or (magit-branch-p branch) (magit-rev-verify branch))
branch)
;; Return as is (might be a commit hash or special ref if not caught above)
(t branch)))
(defun ai-code--verify-branches (base-branch feature-branch)
"Verify that BASE-BRANCH and FEATURE-BRANCH exist.
Signal an error if either branch doesn't exist."
;; Verify base branch exists
(unless (or (magit-branch-p base-branch)
(magit-branch-p (concat "origin/" base-branch))
(magit-rev-verify base-branch))
(user-error "Base branch '%s' not found locally or in remotes" base-branch))
;; Verify feature branch exists (if not HEAD)
(when (and (not (string= feature-branch "HEAD"))
(not (magit-branch-p feature-branch))
(not (magit-branch-p (concat "origin/" feature-branch)))
(not (magit-rev-verify feature-branch)))
(user-error "Feature branch '%s' not found locally or in remotes" feature-branch)))
(defun ai-code--generate-staged-diff (diff-file)
"Generate diff for staged (staged) change and save to DIFF-FILE."
(message "Generating diff for staged (staged) changes...")
(magit-run-git "diff" "--cached" (concat "--output=" diff-file)))
(defun ai-code--resolve-diff-branches (type input-base-branch input-feature-branch &optional branch-scope)
"Resolve base and feature branches for diff generation.
TYPE is `'commit`, `'base-vs-head`, or `'branch-range`.
INPUT-BASE-BRANCH and INPUT-FEATURE-BRANCH are user-provided names.
BRANCH-SCOPE is `'local` or `'remote`, used for `'branch-range`.
Returns a cons cell (RESOLVED-BASE . RESOLVED-FEATURE)."
(let (resolved-base-branch resolved-feature-branch)
(pcase type
('commit
;; Input is already commit^ and commit
(setq resolved-base-branch input-base-branch)
(setq resolved-feature-branch input-feature-branch))
('base-vs-head
;; Base branch can be local or remote, feature is HEAD
(setq resolved-base-branch (ai-code--get-full-branch-ref input-base-branch))
(setq resolved-feature-branch "HEAD")) ; HEAD is always resolved correctly by git
('branch-range
(pcase branch-scope
('local
;; User asserts branches are local
(setq resolved-base-branch input-base-branch)
(setq resolved-feature-branch input-feature-branch))
('remote
;; For remote scope, explicitly try to use origin/ prefixed branches
;; Add origin/ prefix if not already present
(setq resolved-base-branch
(if (string-prefix-p "origin/" input-base-branch)
input-base-branch
(concat "origin/" input-base-branch)))
(setq resolved-feature-branch
(if (string-prefix-p "origin/" input-feature-branch)
input-feature-branch
(concat "origin/" input-feature-branch))))
(_ ; Default or unknown scope, fallback to smart resolution (should not happen with prompt)
(setq resolved-base-branch (ai-code--get-full-branch-ref input-base-branch))
(setq resolved-feature-branch (ai-code--get-full-branch-ref input-feature-branch))))))
(cons resolved-base-branch resolved-feature-branch)))
(defun ai-code--generate-branch-or-commit-diff (diff-params diff-file)
"Generate diff based on DIFF-PARAMS and save to DIFF-FILE.
DIFF-PARAMS is a plist with :type (`'commit`, `'base-vs-head`, `'branch-range`),
:base-branch, :feature-branch, :diff-file-name-part, and optionally
:branch-scope."
(let* ((type (plist-get diff-params :type))
(input-base-branch (plist-get diff-params :base-branch))
(input-feature-branch (plist-get diff-params :feature-branch))
(branch-scope (plist-get diff-params :branch-scope)) ; Might be nil for 'commit' or 'base-vs-head'
(diff-file-name-part (plist-get diff-params :diff-file-name-part))
(resolved-branches (ai-code--resolve-diff-branches type input-base-branch input-feature-branch branch-scope))
(resolved-base-branch (car resolved-branches))
(resolved-feature-branch (cdr resolved-branches)))
(message "Fetching from all remotes to ensure latest branches...")
(magit-run-git "fetch" "--all")
;; Verify input branches for relevant types
(when (memq type '(base-vs-head branch-range))
(ai-code--verify-branches input-base-branch input-feature-branch))
;; Display message about what we're doing
(pcase type
('commit
(message "Generating diff for single commit: %s" diff-file-name-part))
('base-vs-head
(message "Generating diff between %s and HEAD" resolved-base-branch))
('branch-range
(message "Generating diff between branches: %s..%s (%s)"
resolved-base-branch resolved-feature-branch (or branch-scope "unknown-scope"))))
(when (magit-anything-modified-p)
(message "Repository has uncommitted changes. You might want to commit or stash them first.")
(sleep-for 1))
(message "Generating diff file: %s" diff-file)
(magit-run-git "diff" (concat resolved-base-branch ".." resolved-feature-branch)
(concat "--output=" diff-file))))
(defun ai-code--open-diff-file (diff-file)
"Open the generated DIFF-FILE."
(find-file diff-file)
(message "Generated diff file: %s" diff-file))
(defun ai-code--handle-staged-diff-generation (_git-root)
"Handle generation of diff for staged change.
_GIT-ROOT is unused but kept for API compatibility."
(let* ((files-dir (ai-code--ensure-files-directory))
(diff-file-name-part "staged")
(diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir)))
(ai-code--generate-staged-diff diff-file)
diff-file))
(defun ai-code--handle-base-vs-head-diff-generation (_git-root
&optional
open-in-browser)
"Handle generation of diff between a base branch and HEAD.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web
interface without generating file."
(let* ((base-branch (ai-code-read-string "Base branch name: "))
(current-branch (magit-get-current-branch)))
(if open-in-browser
(progn
(ai-code--open-git-web-compare base-branch (or current-branch "HEAD"))
nil) ; Return nil since no file was generated
(let* ((files-dir (ai-code--ensure-files-directory))
(diff-file-name-part (concat (replace-regexp-in-string "/" "-" base-branch) ".HEAD"))
(diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
(diff-params (list :type 'base-vs-head
:base-branch base-branch
:feature-branch "HEAD"
:diff-file-name-part diff-file-name-part)))
(ai-code--generate-branch-or-commit-diff diff-params diff-file)
diff-file))))
(defun ai-code--handle-branch-range-diff-generation (_git-root
&optional
open-in-browser)
"Handle generation of diff between a base and a feature branch.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web
interface without generating file."
(let* ((base-branch (ai-code-read-string "Base branch name: "))
(feature-branch (ai-code-read-string "Feature branch name: ")))
(if open-in-browser
(progn
(ai-code--open-git-web-compare base-branch feature-branch)
nil) ; Return nil since no file was generated
(let* ((branch-scope)
(scope-alist '(("Local" . local)
("Remote (will prefix with 'origin/')" . remote)))
(raw-scope-choice (completing-read "Are branches local or remote? "
scope-alist
nil t nil nil "Local")))
(setq branch-scope
(if (consp raw-scope-choice)
(cdr raw-scope-choice)
(cdr (assoc raw-scope-choice scope-alist))))
(let* ((files-dir (ai-code--ensure-files-directory))
(diff-file-name-part (concat (replace-regexp-in-string "/" "-" base-branch)
"."
(replace-regexp-in-string "/" "-" feature-branch)))
(diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
(diff-params (list :type 'branch-range
:base-branch base-branch
:feature-branch feature-branch
:branch-scope branch-scope
:diff-file-name-part diff-file-name-part)))
(ai-code--generate-branch-or-commit-diff diff-params diff-file)
diff-file)))))
(defun ai-code--handle-commit-diff-generation (_git-root
&optional open-in-browser)
"Handle generation of diff for a single commit.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the commit in GitHub web
interface without generating file."
(let* ((commit-hash (ai-code-read-string "Commit hash: ")))
(if open-in-browser
(progn
(ai-code--open-git-web-commit commit-hash)
nil) ; Return nil since no file was generated
(let* ((files-dir (ai-code--ensure-files-directory))
(base-branch (concat commit-hash "^")) ; Diff against parent
(feature-branch commit-hash)
(diff-file-name-part commit-hash)
(diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
(diff-params (list :type 'commit
:base-branch base-branch
:feature-branch feature-branch
:diff-file-name-part diff-file-name-part)))
(ai-code--generate-branch-or-commit-diff diff-params diff-file)
diff-file))))
(defun ai-code--get-diff-type-choice ()
"Get user's choice for diff type and return the corresponding value."
(let* ((diff-type-alist '(("Staged changes" . staged)
("Base branch vs HEAD" . base-vs-head)
("Branch range (e.g., base..feature)" . branch-range)
("Single commit" . commit)
("Commit range (e.g., commitA..commitB)" . commit-range)))
(raw-diff-type-choice
(completing-read "Select diff type: "
diff-type-alist
nil t nil nil "Staged changes")))
(if (consp raw-diff-type-choice)
(cdr raw-diff-type-choice)
;; If raw-diff-type-choice is a string, look up its corresponding value
(cdr (assoc raw-diff-type-choice diff-type-alist)))))
;;; New helper for commit ranges
(defun ai-code--handle-commit-range-diff-generation (_git-root &optional open-in-browser)
"Handle generation of diff between two commits (commit range).
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web interface
without generating file."
(let* ((raw-start (ai-code-read-string "Start commit or branch: "))
(raw-end (ai-code-read-string "End commit or branch: "))
;; try to resolve remote branches or commits
(start (ai-code--get-full-branch-ref raw-start))
(end (ai-code--get-full-branch-ref raw-end)))
(if open-in-browser
(progn
(ai-code--open-git-web-compare start end)
nil) ; Return nil since no file was generated
;; Sanitize branch names for file path (replace "/" with "-")
(let* ((files-dir (ai-code--ensure-files-directory))
(name-sanitized (concat (replace-regexp-in-string "/" "-" start)
".."
(replace-regexp-in-string "/" "-" end)))
(file (expand-file-name (concat name-sanitized ".diff") files-dir))
;; reuse branch-range plumbing (it will fetch and verify)
(params (list :type 'branch-range
:base-branch start
:feature-branch end
:branch-scope 'remote ; commit-range uses resolved refs
:diff-file-name-part name-sanitized)))
(ai-code--generate-branch-or-commit-diff params file)
file))))
(defun ai-code--get-git-web-repo-url ()
"Get Git repository web URL from git remote.
Returns the HTTPS URL of the repository for web browsing.
Supports GitHub, GitLab, Bitbucket, and other Git hosting services.
Returns nil if unable to construct a web URL."
(let* ((remote-url (magit-git-string "config" "--get" "remote.origin.url")))
(when (and remote-url (stringp remote-url) (not (string-empty-p remote-url)))
(cond
;; HTTPS URL: https://host.com/user/repo.git or https://host.com/user/repo
((string-match "https://\\([^/]+\\)/\\(.+\\)" remote-url)
(let ((host (match-string 1 remote-url))
(path (match-string 2 remote-url)))
(format "https://%s/%s" host (replace-regexp-in-string "\\.git$" "" path))))
;; SSH URL: git@host.com:user/repo.git or git@host.com:user/repo
((string-match "git@\\([^:]+\\):\\(.+\\)" remote-url)
(let ((host (match-string 1 remote-url))
(path (match-string 2 remote-url)))
(format "https://%s/%s" host (replace-regexp-in-string "\\.git$" "" path))))
(t nil)))))
(defun ai-code--open-git-web-compare (start end)
"Open Git web compare page for START..END in browser.
Works with GitHub, GitLab, Bitbucket, and other Git hosting services."
(let ((repo-url (ai-code--get-git-web-repo-url)))
(if repo-url
(let ((compare-url (format "%s/compare/%s...%s" repo-url start end)))
(browse-url compare-url)
(message "Opened web compare: %s" compare-url))
(message "Unable to determine repository web URL"))))
(defun ai-code--open-git-web-commit (commit)
"Open Git web commit page for COMMIT in browser.
Works with GitHub, GitLab, Bitbucket, and other Git hosting services."
(let ((repo-url (ai-code--get-git-web-repo-url)))
(if repo-url
(let ((commit-url (format "%s/commit/%s" repo-url commit)))
(browse-url commit-url)
(message "Opened web commit: %s" commit-url))
(message "Unable to determine repository web URL"))))
(defun ai-code--magit-generate-feature-branch-diff-file ()
"Generate a diff file based on user-selected type (staged, branches, commit).
For non-staged diffs, user is prompted whether to open in browser."
(when-let* ((git-root (ai-code--validate-git-repository)))
(let* ((selected-diff-type-value (ai-code--get-diff-type-choice))
(open-in-browser (and (not (eq selected-diff-type-value 'staged))
(y-or-n-p "Open diff in browser? ")))
(diff-file (pcase selected-diff-type-value
('staged (ai-code--handle-staged-diff-generation git-root))
('base-vs-head (ai-code--handle-base-vs-head-diff-generation git-root open-in-browser))
('branch-range (ai-code--handle-branch-range-diff-generation git-root open-in-browser))
('commit (ai-code--handle-commit-diff-generation git-root open-in-browser))
('commit-range (ai-code--handle-commit-range-diff-generation git-root open-in-browser))
(_ (user-error "Invalid diff type selected")))))
(when diff-file
(ai-code--open-diff-file diff-file)
(when (y-or-n-p "Review this change? ")
(ai-code-pull-or-review-diff-file))))))
;;;###autoload
(defun ai-code-magit-blame-analyze ()
"Analyze current file or region Git history with AI for deeper insights.
If region is active, analyze just that region. Otherwise analyze entire file.
Combines `magit-blame' history tracking with AI analysis to help understand
code evolution and the reasoning behind changes."
(interactive)
(when (ai-code--validate-buffer-file)
(let* ((file-path (buffer-file-name))
(has-region (use-region-p))
(line-start (if has-region
(line-number-at-pos (region-beginning))
1))
(line-end (if has-region
(line-number-at-pos (region-end))
(line-number-at-pos (point-max))))
(region-text (if has-region
(buffer-substring-no-properties
(region-beginning) (region-end))
nil))
(blame-args (list "blame" "-l"
(format "-L%d,%d" line-start line-end)
file-path))
(blame-output (with-temp-buffer
(apply #'process-file "git" nil t nil blame-args)
(buffer-string)))
(context (format "File: %s\nLines: %d-%d\n\n"
file-path line-start line-end))
(files-context-string (ai-code--get-context-files-string))
(code-sample (if has-region
(concat "Selected code:\n```\n" region-text "\n```\n\n")
""))
(default-analysis "Please provide the following analysis:\n1. Code evolution patterns and timeline\n2. Key changes and their purpose\n3. Potential design decisions and thought processes\n4. Possible refactoring or improvement opportunities\n5. Insights about code architecture or design")
(analysis-instructions (ai-code-read-string "Analysis instructions: " default-analysis))
(prompt (format "Analyze the Git commit history for this code:\n\n%s%s%sCommit history information:\n```\n%s\n```\n\n%s"
context files-context-string code-sample blame-output analysis-instructions)))
(ai-code--insert-prompt prompt))))
(defun ai-code--ensure-git-log (git-root repo-name keyword)
"Fetch commits from the last X months under GIT-ROOT for REPO-NAME.
KEYWORD is used to filter commits.
Returns the path to the git.log file."
(let* ((project-log-file-path (expand-file-name "git.log" git-root))
(date-str (ai-code-read-string (format "Start date for history of %s (YYYY-MM-DD, e.g. 2025-01-01): " repo-name)))
(since-arg (unless (string-empty-p date-str)
(format "--since=%s" date-str)))
;; compute defaults, then let user review & edit
(magit-args-default
(let ((args (list "log" "--pretty=medium" "--stat")))
(when since-arg
(setq args (append args (list since-arg))))
(unless (string-empty-p keyword)
(setq args (append args (list "-S" keyword))))
args))
(magit-args-str (mapconcat #'identity magit-args-default " "))
(magit-args-input
(ai-code-read-string (format "Git log args (edit if needed): ") magit-args-str))
(magit-args (split-string magit-args-input nil t))
(log-output (apply #'magit-git-output magit-args)))
(message "Saving Git log to %s" project-log-file-path)
(with-temp-file project-log-file-path
(insert log-output))
(find-file project-log-file-path)
project-log-file-path))
(defun ai-code--default-log-analysis-instructions (keyword)
"Return the default analysis prompt for KEYWORD (may be empty)."
(if (not (string-empty-p keyword))
(format "Analyze the commits filtered by keyword '%s'. Provide insights on:\n\
1. Overall '%s' related feature evolution and major development phases, with author name in each phase.\n\
2. Frequency and patterns of '%s' related commits.\n\
3. Files or areas most impacted by '%s' changes.\n\
4. Main contributors and their roles in '%s' work.\n\
5. Trends or hotspots in '%s' related development.\n\
6. Suggestions for improving or refactoring '%s' implementation.\n"
keyword keyword keyword keyword keyword keyword keyword)
(concat "Please analyze the following Git log for the entire repository. Provide insights on:\n"
"1. Overall project evolution and major development phases, with author name in each phase.\n"
"2. Identification of key features, refactorings, or architectural changes and their timeline, with author name for each one.\n"
"3. Patterns in development activity (e.g., periods of rapid development, bug fixing, etc.), with author name.\n"
"4. Significant contributors or shifts in contribution patterns (if discernible from commit messages).\n"
"5. Potential areas of technical debt or architectural concerns suggested by the commit history.\n"
"6. General trends in the project's direction or focus over time.")))
(defun ai-code--build-log-prompt (repo-name analysis-instructions)
"Build the final AI prompt for REPO-NAME using ANALYSIS-INSTRUCTIONS."
(let ((context (format "Repository: %s\n\n" repo-name)))
(format "Analyze the Git commit history for the entire repository '%s'.\n\n%sThe detailed Git log content is in the 'git.log' file (which has been added to the chat).\nPlease use its content for your analysis, following these instructions:\n%s"
repo-name context analysis-instructions)))
(defun ai-code-magit-log-analyze ()
"Analyze Git log with AI.
If current buffer is visiting a file named \\='git.log\\=', analyze its
content. Otherwise, prompt for number of commits (default 100) and
optionally a keyword, generate the log, save it to
\\='PROJECT_ROOT/git.log\\=', open this file, and then analyze its content."
(interactive)
(let* ((git-root (ai-code--validate-git-repository))
(repo-name (file-name-nondirectory (directory-file-name git-root)))
(keyword (ai-code-read-string "Optional: Keyword to filter commits (leave empty for no filter): "))
(default-analysis (ai-code--default-log-analysis-instructions keyword))
(analysis-instructions (ai-code-read-string "Analysis instructions for repository log: " default-analysis))
(prompt (ai-code--build-log-prompt repo-name analysis-instructions)))
(ai-code--insert-prompt prompt)))
;;;###autoload
(defun ai-code-magit-blame-or-log-analyze (&optional arg)
"If current buffer is git.log, run log analysis.
Otherwise if prefix ARG, run log analysis; else run blame analysis.
ARG is the prefix argument."
(interactive "P")
(cond ((and buffer-file-name
(string-equal (file-name-nondirectory buffer-file-name) "git.log"))
(ai-code-magit-log-analyze))
(arg (ai-code-magit-log-analyze))
(t (ai-code-magit-blame-analyze))))
;;;###autoload
(defun ai-code-magit-setup-transients ()
"Configure AI Code's transient menu entries in Magit.
This function uses `with-eval-after-load` to ensure that the
Magit transients are modified only after Magit itself has been loaded.
Call this function to register the AI Code commands with Magit."
(interactive)
;; Integration with magit: This with-eval-after-load is intentional
;; to provide optional Magit integration without breaking when Magit is absent
;; For magit-diff-popup (usually 'd' in status buffer)
(transient-append-suffix 'magit-diff "r" ; "Extra" group
'("a" "AI Code: Review/generate diff" ai-code-pull-or-review-diff-file))
;; For magit-blame-popup (usually 'B' in status buffer or log)
(transient-append-suffix 'magit-blame "b" ; "Extra" group
'("a" "AI Code: Analyze blame" ai-code-magit-blame-analyze))
;; For magit-log-popup (usually 'l' in status buffer)
(transient-append-suffix 'magit-log "b" ; "Extra" group
'("a" "AI Code: Analyze log" ai-code-magit-log-analyze)))
;; Ensure the Magit transients are set up when this file is loaded.
;; (ai-code-magit-setup-transients)
;;;###autoload
(defun ai-code-init-project (prefix)
"Initialize project helpers for Projectile and Helm-Gtags.
If either package is available, prompt for a project directory
defaulting to the Magit repository root, initialize the project in
Projectile, and configure Helm-Gtags with a pygments label. Show a
summary message of performed actions.
PREFIX is the prefix argument."
(interactive "P")
;; DONE: this function should create ai-code-files-dir-name folder under git root direction
(let* ((projectile-available (or (featurep 'projectile)
(require 'projectile nil t)))
(helm-gtags-available (or (featurep 'helm-gtags)
(require 'helm-gtags nil t))))
(cond
((not (or projectile-available helm-gtags-available))
(message "Projectile and Helm-Gtags are not available; skipping project initialization."))
((not (require 'magit nil t))
(message "Magit is not available; cannot determine project root."))
(t
(let* ((git-root (ai-code--git-root))
(initial-dir (or git-root default-directory))
(dir (expand-file-name
(read-directory-name "Initialize project at: "
initial-dir nil t initial-dir))))
(let ((gtags-label (when (and helm-gtags-available
(fboundp 'helm-gtags--read-gtagslabel))
(if prefix
(helm-gtags--read-gtagslabel)
ai-code-init-project-gtags-label)))
actions)
(when (and projectile-available
(fboundp 'projectile-init-project))
(projectile-init-project dir)
(push (format "initialized Projectile project at %s" dir) actions))
(cond
((and helm-gtags-available
(fboundp 'helm-gtags--read-gtagslabel)
gtags-label)
(helm-gtags-create-tags dir gtags-label)
(push (format "set Helm-Gtags label to %s for %s" gtags-label dir)
actions))
((and helm-gtags-available prefix)
(push (format "skipped Helm-Gtags label for %s" dir) actions)))
(when git-root
(let ((files-dir (expand-file-name ai-code-files-dir-name git-root)))
(unless (file-directory-p files-dir)
(make-directory files-dir t)
(push (format "created %s in %s" ai-code-files-dir-name git-root)
actions))))
(if actions
(message "ai-code-init-project: %s"
(mapconcat #'identity (nreverse actions) "; "))
(message "ai-code-init-project: no actions performed for %s" dir))
(ai-code-update-git-ignore)))))))
;;;###autoload
(defun ai-code-update-git-ignore ()
"Ensure repository .gitignore has AI Code-related entries.
If not inside a Git repository, do nothing."
(interactive)
(let ((git-root (ai-code--git-root)))
(if (not git-root)
(message "ai-code-update-git-ignore: not in a git repository, skipped")
(let* ((gitignore-path (expand-file-name ".gitignore" git-root))
(required-entries (list (concat ai-code-files-dir-name "/")
".projectile"
"GTAGS"
"GRTAGS"
"GPATH"
"__pycache__/"
"*.elc"
"flycheck_*"))
(gitignore-content (when (file-exists-p gitignore-path)
(with-temp-buffer
(insert-file-contents gitignore-path)
(buffer-string))))
(missing-entries nil))
(dolist (entry required-entries)
(unless (and gitignore-content
(string-match-p (concat "\\(?:^\\|\n\\)\\s-*"
(regexp-quote entry)
"\\s-*\\(?:\n\\|$\\)")
gitignore-content))
(unless (member entry missing-entries)
(push entry missing-entries))))
(if missing-entries
(let ((added (nreverse missing-entries)))
(with-temp-buffer
(when gitignore-content
(insert gitignore-content)
(unless (or (zerop (buffer-size))
(eq (char-before) ?\n))
(insert "\n")))
(dolist (entry added)
(insert entry "\n"))
(write-region (point-min) (point-max) gitignore-path))
(message "ai-code-update-git-ignore: added %s"
(mapconcat #'identity added ", ")))
(message "ai-code-update-git-ignore: no updates needed"))))))
;;;###autoload
(defun ai-code--git-repo-recent-modified-files (base-dir limit)
"Return up to LIMIT most recently modified files under BASE-DIR.
If BASE-DIR is in a Git repository, use `git ls-files' to enumerate files."
(let* ((git-root (ai-code--git-root base-dir))
(root (or git-root base-dir))
(files (if git-root
(let ((default-directory root))
(mapcar (lambda (path)
(expand-file-name path root))
(magit-git-lines "ls-files")))
(directory-files root t directory-files-no-dot-files-regexp)))
(file-times nil))
(dolist (file files)
(when (and (file-regular-p file)
(not (ai-code--git-ignored-repo-file-p file root)))
(let* ((attrs (file-attributes file))
(mtime (file-attribute-modification-time attrs)))
(push (cons file mtime) file-times))))
(setq file-times (sort file-times
(lambda (a b)
(time-less-p (cdr b) (cdr a)))))
(let ((recent '())
(count 0))
(dolist (item file-times)
(when (< count limit)
(push (car item) recent)
(setq count (1+ count))))
(delete-dups (nreverse recent)))))
;;;###autoload
(defun ai-code-git-repo-recent-modified-files (prefix)
"Open or insert a recently modified file in the repo or current dir.