Files
dotfiles/.emacs.d/lisp/simple-git.el
2025-12-30 15:39:36 -05:00

1152 lines
49 KiB
EmacsLisp

;;; simple-git.el --- Simple Git interface for Emacs -*- lexical-binding: t; -*-
;; Author: Max Amundsen
;; Version: 1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: git, vc, tools
;;; Commentary:
;; A lightweight Git interface providing:
;; - Status view with staging/unstaging
;; - Commit with message
;; - Push/pull
;; - Commit history log
;;; Code:
(require 'cl-lib)
;;; ============================================================================
;;; Customization
;;; ============================================================================
(defgroup simple-git nil
"Simple Git interface."
:group 'tools
:prefix "simple-git-")
(defface simple-git-header-face
'((t :inherit font-lock-keyword-face :weight bold))
"Face for headers in simple-git buffers.")
(defface simple-git-staged-face
'((t :inherit font-lock-string-face))
"Face for staged files.")
(defface simple-git-unstaged-face
'((t :inherit font-lock-warning-face))
"Face for unstaged/modified files.")
(defface simple-git-untracked-face
'((t :inherit font-lock-comment-face))
"Face for untracked files.")
(defface simple-git-commit-hash-face
'((t :inherit font-lock-constant-face))
"Face for commit hashes.")
(defface simple-git-commit-author-face
'((t :inherit font-lock-type-face))
"Face for commit authors.")
(defface simple-git-commit-date-face
'((t :inherit font-lock-comment-face))
"Face for commit dates.")
;;; ============================================================================
;;; Utility Functions
;;; ============================================================================
(defun simple-git--root ()
"Get the root directory of the current Git repository."
(let ((root (locate-dominating-file default-directory ".git")))
(when root (expand-file-name root))))
(defun simple-git--run (&rest args)
"Run git command with ARGS and return output as string."
(let ((default-directory (or (simple-git--root) default-directory)))
(with-temp-buffer
(apply #'call-process "git" nil t nil args)
(buffer-string))))
(defun simple-git--run-async (callback &rest args)
"Run git command with ARGS asynchronously, call CALLBACK with output."
(let* ((default-directory (or (simple-git--root) default-directory))
(buf (generate-new-buffer " *simple-git-async*"))
(proc (apply #'start-process "simple-git" buf "git" args)))
(set-process-sentinel
proc
(lambda (p _event)
(when (eq (process-status p) 'exit)
(with-current-buffer (process-buffer p)
(funcall callback (buffer-string)))
(kill-buffer (process-buffer p)))))))
(defun simple-git--in-repo-p ()
"Return non-nil if current directory is in a Git repository."
(simple-git--root))
;;; ============================================================================
;;; Status Mode
;;; ============================================================================
(defvar simple-git-status-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "g") #'simple-git-status-refresh)
(define-key map (kbd "s") #'simple-git-stage-file)
(define-key map (kbd "u") #'simple-git-unstage-file)
(define-key map (kbd "S") #'simple-git-stage-all)
(define-key map (kbd "U") #'simple-git-unstage-all)
(define-key map (kbd "r") #'simple-git-revert-file)
(define-key map (kbd "c") #'simple-git-commit)
(define-key map (kbd "P") #'simple-git-push)
(define-key map (kbd "F") #'simple-git-pull)
(define-key map (kbd "b") #'simple-git-switch-branch)
(define-key map (kbd "B") #'simple-git-branch-graph)
(define-key map (kbd "l") #'simple-git-log)
(define-key map (kbd "d") #'simple-git-diff-file)
(define-key map (kbd "m") #'simple-git-merge)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "RET") #'simple-git-visit-file)
(define-key map (kbd "n") #'next-line)
(define-key map (kbd "p") #'previous-line)
map)
"Keymap for `simple-git-status-mode'.")
(define-derived-mode simple-git-status-mode special-mode "SimpleGit:Status"
"Major mode for simple-git status buffer."
(setq buffer-read-only t)
(setq truncate-lines t))
(defun simple-git--parse-status ()
"Parse git status --porcelain output into structured data."
(let ((output (simple-git--run "status" "--porcelain" "-uall"))
staged unstaged untracked)
(dolist (line (split-string output "\n" t))
(when (>= (length line) 3)
(let ((index-status (aref line 0))
(worktree-status (aref line 1))
(file (substring line 3)))
(cond
;; Staged changes
((and (not (eq index-status ?\s))
(not (eq index-status ??)))
(push (cons file (char-to-string index-status)) staged))
;; Untracked
((eq index-status ??)
(push file untracked))
;; Unstaged modifications
((and (eq index-status ?\s)
(not (eq worktree-status ?\s)))
(push (cons file (char-to-string worktree-status)) unstaged)))
;; Handle files that are both staged and have unstaged changes
(when (and (not (eq index-status ?\s))
(not (eq index-status ??))
(not (eq worktree-status ?\s)))
(push (cons file (char-to-string worktree-status)) unstaged)))))
(list :staged (nreverse staged)
:unstaged (nreverse unstaged)
:untracked (nreverse untracked))))
(defun simple-git--get-branch ()
"Get current branch name."
(string-trim (simple-git--run "rev-parse" "--abbrev-ref" "HEAD")))
(defun simple-git--get-remote-status ()
"Get ahead/behind status relative to remote."
(let* ((branch (simple-git--get-branch))
(remote-ref (format "origin/%s" branch))
(output (simple-git--run "rev-list" "--left-right" "--count"
(format "%s...%s" branch remote-ref))))
(when (string-match "\\([0-9]+\\)\\s-+\\([0-9]+\\)" output)
(let ((ahead (string-to-number (match-string 1 output)))
(behind (string-to-number (match-string 2 output))))
(cond
((and (> ahead 0) (> behind 0))
(format " [ahead %d, behind %d]" ahead behind))
((> ahead 0)
(format " [ahead %d]" ahead))
((> behind 0)
(format " [behind %d]" behind))
(t ""))))))
(defun simple-git-status-refresh ()
"Refresh the status buffer."
(interactive)
(when (eq major-mode 'simple-git-status-mode)
(let ((inhibit-read-only t)
(pos (point))
(status (simple-git--parse-status))
(branch (simple-git--get-branch))
(remote-status (or (simple-git--get-remote-status) "")))
(erase-buffer)
;; Header
(insert (propertize (format "Git Status: %s" (simple-git--root))
'face 'simple-git-header-face)
"\n")
(insert (propertize (format "Branch: %s%s" branch remote-status)
'face 'simple-git-header-face)
"\n\n")
;; Help
(insert "Commands: ")
(insert (propertize "s" 'face 'font-lock-keyword-face) "tage ")
(insert (propertize "u" 'face 'font-lock-keyword-face) "nstage ")
(insert (propertize "S" 'face 'font-lock-keyword-face) "tage-all ")
(insert (propertize "U" 'face 'font-lock-keyword-face) "nstage-all ")
(insert (propertize "c" 'face 'font-lock-keyword-face) "ommit ")
(insert (propertize "P" 'face 'font-lock-keyword-face) "ush ")
(insert (propertize "F" 'face 'font-lock-keyword-face) "etch/pull ")
(insert (propertize "b" 'face 'font-lock-keyword-face) "ranch ")
(insert (propertize "B" 'face 'font-lock-keyword-face) "ranch-graph ")
(insert (propertize "l" 'face 'font-lock-keyword-face) "og ")
(insert (propertize "d" 'face 'font-lock-keyword-face) "iff ")
(insert (propertize "m" 'face 'font-lock-keyword-face) "erge ")
(insert (propertize "r" 'face 'font-lock-keyword-face) "evert ")
(insert (propertize "g" 'face 'font-lock-keyword-face) " refresh ")
(insert (propertize "q" 'face 'font-lock-keyword-face) "uit")
(insert "\n\n")
;; Staged files
(let ((staged (plist-get status :staged)))
(insert (propertize (format "Staged (%d):\n" (length staged))
'face 'simple-git-header-face))
(if staged
(dolist (item staged)
(let ((line-start (point)))
(insert " " (propertize (format "[%s] " (cdr item))
'face 'simple-git-staged-face)
(propertize (car item) 'face 'simple-git-staged-face)
"\n")
(put-text-property line-start (point) 'simple-git-file (car item))
(put-text-property line-start (point) 'simple-git-staged t)))
(insert " (none)\n")))
(insert "\n")
;; Unstaged files
(let ((unstaged (plist-get status :unstaged)))
(insert (propertize (format "Unstaged (%d):\n" (length unstaged))
'face 'simple-git-header-face))
(if unstaged
(dolist (item unstaged)
(let ((line-start (point)))
(insert " " (propertize (format "[%s] " (cdr item))
'face 'simple-git-unstaged-face)
(propertize (car item) 'face 'simple-git-unstaged-face)
"\n")
(put-text-property line-start (point) 'simple-git-file (car item))
(put-text-property line-start (point) 'simple-git-staged nil)
(put-text-property line-start (point) 'simple-git-unstaged t)))
(insert " (none)\n")))
(insert "\n")
;; Untracked files
(let ((untracked (plist-get status :untracked)))
(insert (propertize (format "Untracked (%d):\n" (length untracked))
'face 'simple-git-header-face))
(if untracked
(dolist (file untracked)
(let ((line-start (point)))
(insert " " (propertize file 'face 'simple-git-untracked-face)
"\n")
(put-text-property line-start (point) 'simple-git-file file)
(put-text-property line-start (point) 'simple-git-untracked t)))
(insert " (none)\n")))
(goto-char (min pos (point-max))))))
(defun simple-git--file-at-point ()
"Get the file path at point."
(get-text-property (line-beginning-position) 'simple-git-file))
(defun simple-git--staged-at-point ()
"Return t if file at point is staged."
(get-text-property (line-beginning-position) 'simple-git-staged))
(defun simple-git--unstaged-at-point ()
"Return t if file at point is unstaged (modified but not staged)."
(get-text-property (line-beginning-position) 'simple-git-unstaged))
(defun simple-git--untracked-at-point ()
"Return t if file at point is untracked."
(get-text-property (line-beginning-position) 'simple-git-untracked))
(defun simple-git-stage-file ()
"Stage the file at point."
(interactive)
(if-let ((file (simple-git--file-at-point)))
(let ((default-directory (simple-git--root)))
(simple-git--run "add" "--" file)
(simple-git-status-refresh)
(message "Staged: %s" file))
(message "No file at point")))
(defun simple-git-unstage-file ()
"Unstage the file at point."
(interactive)
(if-let ((file (simple-git--file-at-point)))
(if (simple-git--staged-at-point)
(let ((default-directory (simple-git--root)))
(simple-git--run "reset" "HEAD" "--" file)
(simple-git-status-refresh)
(message "Unstaged: %s" file))
(message "File is not staged"))
(message "No file at point")))
(defun simple-git-revert-file ()
"Revert unstaged changes in file at point (restore to last committed state)."
(interactive)
(if-let ((file (simple-git--file-at-point)))
(cond
((simple-git--untracked-at-point)
(message "Cannot revert untracked file (use delete instead)"))
((simple-git--staged-at-point)
(message "Cannot revert staged file (unstage first)"))
((simple-git--unstaged-at-point)
(if (yes-or-no-p (format "Revert changes to %s? " file))
(let ((default-directory (simple-git--root)))
(simple-git--run "checkout" "--" file)
(simple-git-status-refresh)
(message "Reverted: %s" file))
(message "Revert cancelled")))
(t
(message "No unstaged changes to revert")))
(message "No file at point")))
(defun simple-git-stage-all ()
"Stage all changes."
(interactive)
(simple-git--run "add" "-A")
(simple-git-status-refresh)
(message "Staged all changes"))
(defun simple-git-unstage-all ()
"Unstage all changes."
(interactive)
(simple-git--run "reset" "HEAD")
(simple-git-status-refresh)
(message "Unstaged all changes"))
(defun simple-git-diff-file ()
"Show diff for file at point."
(interactive)
(if-let ((file (simple-git--file-at-point)))
(let ((buf (get-buffer-create "*simple-git-diff*"))
(staged (simple-git--staged-at-point))
(root (simple-git--root)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
(if staged
(call-process "git" nil t nil "diff" "--cached" "--" file)
(call-process "git" nil t nil "diff" "--" file))
(goto-char (point-min))
(diff-mode)
(setq buffer-read-only t)))
(display-buffer buf))
(message "No file at point")))
(defun simple-git-visit-file ()
"Visit the file at point."
(interactive)
(when-let ((file (simple-git--file-at-point)))
(find-file (expand-file-name file (simple-git--root)))))
;;; ============================================================================
;;; Commit
;;; ============================================================================
(defvar simple-git--commit-window-config nil
"Window configuration before entering commit message.")
(defvar simple-git-commit-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-s") #'simple-git-commit-finish)
(define-key map (kbd "C-g") #'simple-git-commit-cancel)
map)
"Keymap for `simple-git-commit-mode'.")
(define-derived-mode simple-git-commit-mode text-mode "SimpleGit:Commit"
"Major mode for writing commit messages."
(setq-local header-line-format
"Commit message. C-s to commit, C-g to cancel."))
(defun simple-git-commit ()
"Start composing a commit message."
(interactive)
(let ((status (simple-git--parse-status)))
(if (null (plist-get status :staged))
(message "Nothing staged to commit")
(setq simple-git--commit-window-config (current-window-configuration))
(let ((buf (get-buffer-create "*simple-git-commit*")))
(pop-to-buffer buf)
(erase-buffer)
(simple-git-commit-mode)
(insert "\n\n# Enter commit message above.\n# Lines starting with '#' will be ignored.\n#\n# Changes to be committed:\n")
(dolist (item (plist-get status :staged))
(insert (format "# %s: %s\n" (cdr item) (car item))))
(goto-char (point-min))))))
(defun simple-git-commit-finish ()
"Finish the commit with the current message."
(interactive)
(let* ((content (buffer-string))
(lines (split-string content "\n"))
(message-lines (cl-remove-if (lambda (l) (string-prefix-p "#" l)) lines))
(message (string-trim (mapconcat #'identity message-lines "\n"))))
(if (string-empty-p message)
(message "Aborting commit due to empty message")
(let ((default-directory (simple-git--root)))
(with-temp-buffer
(call-process "git" nil t nil "commit" "-m" message)
(message "%s" (string-trim (buffer-string)))))
(kill-buffer)
(when simple-git--commit-window-config
(set-window-configuration simple-git--commit-window-config)
(setq simple-git--commit-window-config nil))
(when (get-buffer "*simple-git-status*")
(with-current-buffer "*simple-git-status*"
(simple-git-status-refresh))))))
(defun simple-git-commit-cancel ()
"Cancel the commit."
(interactive)
(kill-buffer)
(when simple-git--commit-window-config
(set-window-configuration simple-git--commit-window-config)
(setq simple-git--commit-window-config nil))
(message "Commit cancelled"))
;;; ============================================================================
;;; Push / Pull
;;; ============================================================================
(defun simple-git-push ()
"Push to remote."
(interactive)
(let ((branch (simple-git--get-branch)))
(message "Pushing %s to origin..." branch)
(simple-git--run-async
(lambda (output)
(message "%s" (string-trim output))
(when (get-buffer "*simple-git-status*")
(with-current-buffer "*simple-git-status*"
(simple-git-status-refresh))))
"push" "-u" "origin" branch)))
(defun simple-git-pull ()
"Pull from remote."
(interactive)
(message "Pulling from origin...")
(simple-git--run-async
(lambda (output)
(message "%s" (string-trim output))
(when (get-buffer "*simple-git-status*")
(with-current-buffer "*simple-git-status*"
(simple-git-status-refresh))))
"pull"))
;;; ============================================================================
;;; Branch
;;; ============================================================================
(defun simple-git--list-branches ()
"Return list of all branches (local and remote)."
(let* ((output (simple-git--run "branch" "-a" "--format=%(refname:short)"))
(branches (split-string output "\n" t)))
;; Remove duplicates and clean up remote branch names
(delete-dups
(mapcar (lambda (b)
(if (string-prefix-p "origin/" b)
(substring b 7)
b))
branches))))
(defun simple-git-switch-branch ()
"Switch to a different branch using completion."
(interactive)
(let* ((branches (simple-git--list-branches))
(current (simple-git--get-branch))
(branches-sorted (cons current (delete current branches)))
(choice (completing-read
(format "Switch branch (current: %s): " current)
branches-sorted nil nil nil nil current)))
(if (string= choice current)
(message "Already on branch %s" current)
(let* ((default-directory (simple-git--root))
(output (simple-git--run "checkout" choice)))
(message "%s" (string-trim output))
(when (get-buffer "*simple-git-status*")
(with-current-buffer "*simple-git-status*"
(simple-git-status-refresh)))))))
(defun simple-git-merge ()
"Merge another branch into the current branch."
(interactive)
(let* ((branches (simple-git--list-branches))
(current (simple-git--get-branch))
(other-branches (delete current (copy-sequence branches)))
(choice (completing-read
(format "Merge into %s from: " current)
other-branches nil t)))
(when (and choice (not (string-empty-p choice)))
(let* ((default-directory (simple-git--root))
(output (simple-git--run "merge" choice)))
(message "%s" (string-trim output))
(when (get-buffer "*simple-git-status*")
(with-current-buffer "*simple-git-status*"
(simple-git-status-refresh)))))))
;;; ============================================================================
;;; Log Mode
;;; ============================================================================
(defvar simple-git-log-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'simple-git-log-show-commit)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "n") #'next-line)
(define-key map (kbd "p") #'previous-line)
(define-key map (kbd "g") #'simple-git-log-refresh)
map)
"Keymap for `simple-git-log-mode'.")
(define-derived-mode simple-git-log-mode special-mode "SimpleGit:Log"
"Major mode for simple-git log buffer."
(setq buffer-read-only t)
(setq truncate-lines t))
(defvar simple-git-log-count 50
"Number of commits to show in log.")
(defun simple-git-log-refresh ()
"Refresh the log buffer."
(interactive)
(when (eq major-mode 'simple-git-log-mode)
(let ((inhibit-read-only t)
(pos (point))
(default-directory (simple-git--root)))
(erase-buffer)
(insert (propertize "Commit History" 'face 'simple-git-header-face) "\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show commit "
(propertize "g" 'face 'font-lock-keyword-face) " refresh "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
(let ((output (simple-git--run "log"
(format "-n%d" simple-git-log-count)
"--pretty=format:%h|%an|%ar|%s")))
(dolist (line (split-string output "\n" t))
(let* ((parts (split-string line "|"))
(hash (nth 0 parts))
(author (nth 1 parts))
(date (nth 2 parts))
(subject (nth 3 parts)))
(insert (propertize hash 'face 'simple-git-commit-hash-face
'simple-git-commit hash)
" "
(propertize (format "%-20s" (truncate-string-to-width (or author "") 20))
'face 'simple-git-commit-author-face)
" "
(propertize (format "%-15s" (truncate-string-to-width (or date "") 15))
'face 'simple-git-commit-date-face)
" "
(or subject "")
"\n"))))
(goto-char (min pos (point-max))))))
(defun simple-git--commit-at-point ()
"Get the commit hash at point."
(get-text-property (line-beginning-position) 'simple-git-commit))
;;; ============================================================================
;;; Commit Detail Mode
;;; ============================================================================
(defvar simple-git-commit-detail-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'simple-git-show-file-diff)
(define-key map (kbd "v") #'simple-git-commit-view-file)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "n") #'next-line)
(define-key map (kbd "p") #'previous-line)
map)
"Keymap for `simple-git-commit-detail-mode'.")
(define-derived-mode simple-git-commit-detail-mode special-mode "SimpleGit:Commit"
"Major mode for viewing commit details with expandable file diffs."
(setq buffer-read-only t)
(setq truncate-lines t))
(defun simple-git-log-show-commit ()
"Show the commit at point with per-file breakdown."
(interactive)
(when-let ((hash (simple-git--commit-at-point)))
(let ((buf (get-buffer-create "*simple-git-commit-detail*"))
(root (simple-git--root)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
;; Get commit info
(let* ((info (simple-git--run "show" "--no-patch"
"--format=%h%n%an%n%ar%n%s%n%b" hash))
(lines (split-string info "\n"))
(short-hash (nth 0 lines))
(author (nth 1 lines))
(date (nth 2 lines))
(subject (nth 3 lines))
(body (string-trim (mapconcat #'identity (nthcdr 4 lines) "\n"))))
;; Header
(insert (propertize "Commit: " 'face 'simple-git-header-face)
(propertize short-hash 'face 'simple-git-commit-hash-face) "\n")
(insert (propertize "Author: " 'face 'simple-git-header-face)
(propertize author 'face 'simple-git-commit-author-face) "\n")
(insert (propertize "Date: " 'face 'simple-git-header-face)
(propertize date 'face 'simple-git-commit-date-face) "\n\n")
(insert (propertize subject 'face 'bold) "\n")
(when (not (string-empty-p body))
(insert "\n" body "\n"))
(insert "\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show diff "
(propertize "v" 'face 'font-lock-keyword-face) "iew file at this point "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
;; Get list of changed files
(let* ((files-output (simple-git--run "show" "--name-status" "--format=" hash))
(file-lines (split-string files-output "\n" t)))
(insert (propertize "Changed files:\n" 'face 'simple-git-header-face))
(dolist (file-line file-lines)
(when (string-match "^\\([AMDRT]\\)\t\\(.+\\)$" file-line)
(let ((status (match-string 1 file-line))
(file (match-string 2 file-line)))
(let ((line-start (point)))
(insert " "
(propertize (format "[%s] " status)
'face (pcase status
("A" 'simple-git-staged-face)
("D" 'simple-git-unstaged-face)
(_ 'simple-git-untracked-face)))
file "\n")
(put-text-property line-start (point) 'simple-git-commit-file file)
(put-text-property line-start (point) 'simple-git-commit-hash hash))))))))
(simple-git-commit-detail-mode)
(goto-char (point-min)))
(display-buffer buf))))
(defun simple-git--file-at-point-commit ()
"Get file and commit hash at point in commit detail buffer."
(list (get-text-property (line-beginning-position) 'simple-git-commit-file)
(get-text-property (line-beginning-position) 'simple-git-commit-hash)))
(defun simple-git-commit-view-file ()
"Open the file at point as it was at that commit."
(interactive)
(let* ((info (simple-git--file-at-point-commit))
(file (nth 0 info))
(hash (nth 1 info)))
(if (and file hash)
(let* ((root (simple-git--root))
(buf-name (format "*simple-git:%s@%s*" (file-name-nondirectory file) hash))
(buf (get-buffer-create buf-name)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
(call-process "git" nil t nil "show" (concat hash ":" file))
(goto-char (point-min))
;; Set mode based on file extension
(let ((mode (assoc-default file auto-mode-alist 'string-match)))
(when mode (funcall mode)))
(setq buffer-read-only t)))
;; Display in main window, not side window
(let ((main-window (or (seq-find (lambda (w)
(not (window-parameter w 'window-side)))
(window-list))
(selected-window))))
(select-window main-window)
(switch-to-buffer buf)))
(message "No file at point"))))
(defvar simple-git-diff-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'simple-git-diff-quit)
(define-key map (kbd "n") #'diff-hunk-next)
(define-key map (kbd "p") #'diff-hunk-prev)
map)
"Keymap for `simple-git-diff-mode'.")
(define-derived-mode simple-git-diff-mode diff-mode "SimpleGit:Diff"
"Major mode for viewing diffs with simple-git."
(setq buffer-read-only t))
(defvar-local simple-git--diff-return-buffer nil
"Buffer to return to when quitting diff.")
(defun simple-git-diff-quit ()
"Quit diff and return to previous buffer."
(interactive)
(let ((return-buf simple-git--diff-return-buffer))
(quit-window)
(when (and return-buf (buffer-live-p return-buf))
(pop-to-buffer return-buf))))
(defun simple-git-show-file-diff ()
"Show diff for file at point in a separate buffer."
(interactive)
(let* ((info (simple-git--file-at-point-commit))
(file (nth 0 info))
(hash (nth 1 info))
(return-buf (current-buffer)))
(if (and file hash)
(let ((buf (get-buffer-create "*simple-git-diff*"))
(root (simple-git--root)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
(call-process "git" nil t nil "show" "--format=" hash "--" file)
(goto-char (point-min))
(simple-git-diff-mode)
(setq simple-git--diff-return-buffer return-buf)))
(display-buffer buf))
(message "No file at point"))))
;;;###autoload
(defun simple-git-log ()
"Show Git commit history."
(interactive)
(unless (simple-git--in-repo-p)
(user-error "Not in a Git repository"))
(let ((buf (get-buffer-create "*simple-git-log*")))
(with-current-buffer buf
(simple-git-log-mode)
(simple-git-log-refresh))
(pop-to-buffer buf)))
;;; ============================================================================
;;; File History Mode
;;; ============================================================================
(defvar simple-git-file-history-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'simple-git-file-history-show-diff)
(define-key map (kbd "v") #'simple-git-file-history-view-file)
(define-key map (kbd "c") #'simple-git-file-history-show-commit)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "n") #'next-line)
(define-key map (kbd "p") #'previous-line)
(define-key map (kbd "g") #'simple-git-file-history-refresh)
map)
"Keymap for `simple-git-file-history-mode'.")
(define-derived-mode simple-git-file-history-mode special-mode "SimpleGit:FileHistory"
"Major mode for viewing file history."
(setq buffer-read-only t)
(setq truncate-lines t))
(defvar-local simple-git--file-history-file nil
"The file being viewed in file history mode.")
(defvar-local simple-git--file-history-root nil
"The git root for file history mode.")
(defun simple-git-file-history-refresh ()
"Refresh the file history buffer."
(interactive)
(when (eq major-mode 'simple-git-file-history-mode)
(let ((inhibit-read-only t)
(pos (point))
(file simple-git--file-history-file)
(root simple-git--file-history-root))
(erase-buffer)
(insert (propertize "File History" 'face 'simple-git-header-face) "\n")
(insert (propertize "File: " 'face 'simple-git-header-face)
file "\n\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show diff "
(propertize "v" 'face 'font-lock-keyword-face) "iew file at this point "
(propertize "c" 'face 'font-lock-keyword-face) "ommit view "
(propertize "g" 'face 'font-lock-keyword-face) " refresh "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
;; Get log with file names to track renames
(let* ((default-directory root)
(output (simple-git--run "log" "--follow" "--name-status"
(format "-n%d" simple-git-log-count)
"--pretty=format:%h|%an|%ar|%s"
"--" file))
(lines (split-string output "\n"))
(current-file file))
;; Parse output - each commit has format line, then blank, then file status
(let ((i 0))
(while (< i (length lines))
(let ((line (nth i lines)))
(when (string-match "^\\([a-f0-9]+\\)|\\(.*\\)|\\(.*\\)|\\(.*\\)$" line)
(let* ((hash (match-string 1 line))
(author (match-string 2 line))
(date (match-string 3 line))
(subject (match-string 4 line))
(file-at-commit current-file))
;; Look ahead for file status line (may show rename)
(let ((j (1+ i)))
(while (and (< j (length lines))
(not (string-match "^[a-f0-9]+|" (nth j lines))))
(let ((status-line (nth j lines)))
(cond
;; Rename: R100\told-name\tnew-name
((string-match "^R[0-9]*\t\\(.+\\)\t\\(.+\\)$" status-line)
(setq file-at-commit (match-string 1 status-line))
(setq current-file (match-string 1 status-line)))
;; Regular change: M\tfilename or A\tfilename etc
((string-match "^[MADC]\t\\(.+\\)$" status-line)
(setq file-at-commit (match-string 1 status-line)))))
(setq j (1+ j))))
(let ((line-start (point)))
(insert (propertize hash 'face 'simple-git-commit-hash-face)
" "
(propertize (format "%-20s" (truncate-string-to-width (or author "") 20))
'face 'simple-git-commit-author-face)
" "
(propertize (format "%-15s" (truncate-string-to-width (or date "") 15))
'face 'simple-git-commit-date-face)
" "
(or subject "")
"\n")
(put-text-property line-start (point) 'simple-git-commit-hash hash)
(put-text-property line-start (point) 'simple-git-commit-file file-at-commit)))))
(setq i (1+ i)))))
(goto-char (min pos (point-max))))))
(defun simple-git-file-history-show-diff ()
"Show diff for file at the commit on current line."
(interactive)
(let* ((hash (get-text-property (line-beginning-position) 'simple-git-commit-hash))
(file (get-text-property (line-beginning-position) 'simple-git-commit-file))
(root simple-git--file-history-root)
(return-buf (current-buffer)))
(if (and hash file)
(let ((buf (get-buffer-create "*simple-git-diff*")))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
(call-process "git" nil t nil "show" "--format=" hash "--" file)
(goto-char (point-min))
(simple-git-diff-mode)
(setq simple-git--diff-return-buffer return-buf)))
(display-buffer buf))
(message "No commit at point"))))
(defun simple-git-file-history-view-file ()
"View file as it was at the commit on current line."
(interactive)
(let* ((hash (get-text-property (line-beginning-position) 'simple-git-commit-hash))
(file (get-text-property (line-beginning-position) 'simple-git-commit-file))
(root simple-git--file-history-root))
(if (and hash file)
(let* ((buf-name (format "*simple-git:%s@%s*" (file-name-nondirectory file) hash))
(buf (get-buffer-create buf-name)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
(call-process "git" nil t nil "show" (concat hash ":" file))
(goto-char (point-min))
;; Set mode based on file extension
(let ((mode (assoc-default file auto-mode-alist 'string-match)))
(when mode (funcall mode)))
(setq buffer-read-only t)))
;; Display in main window, not side window
(let ((main-window (or (seq-find (lambda (w)
(not (window-parameter w 'window-side)))
(window-list))
(selected-window))))
(select-window main-window)
(switch-to-buffer buf)))
(message "No commit at point"))))
(defun simple-git-file-history-show-commit ()
"Show full commit details for the commit on current line."
(interactive)
(let* ((hash (get-text-property (line-beginning-position) 'simple-git-commit-hash))
(root simple-git--file-history-root))
(if hash
(let ((buf (get-buffer-create "*simple-git-commit-detail*")))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
;; Get commit info
(let* ((info (simple-git--run "show" "--no-patch"
"--format=%h%n%an%n%ar%n%s%n%b" hash))
(lines (split-string info "\n"))
(short-hash (nth 0 lines))
(author (nth 1 lines))
(date (nth 2 lines))
(subject (nth 3 lines))
(body (string-trim (mapconcat #'identity (nthcdr 4 lines) "\n"))))
;; Header
(insert (propertize "Commit: " 'face 'simple-git-header-face)
(propertize short-hash 'face 'simple-git-commit-hash-face) "\n")
(insert (propertize "Author: " 'face 'simple-git-header-face)
(propertize author 'face 'simple-git-commit-author-face) "\n")
(insert (propertize "Date: " 'face 'simple-git-header-face)
(propertize date 'face 'simple-git-commit-date-face) "\n\n")
(insert (propertize subject 'face 'bold) "\n")
(when (not (string-empty-p body))
(insert "\n" body "\n"))
(insert "\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show diff "
(propertize "v" 'face 'font-lock-keyword-face) "iew file at this point "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
;; Get list of changed files
(let* ((files-output (simple-git--run "show" "--name-status" "--format=" hash))
(file-lines (split-string files-output "\n" t)))
(insert (propertize "Changed files:\n" 'face 'simple-git-header-face))
(dolist (file-line file-lines)
(when (string-match "^\\([AMDRT]\\)\t\\(.+\\)$" file-line)
(let ((status (match-string 1 file-line))
(file (match-string 2 file-line)))
(let ((line-start (point)))
(insert " "
(propertize (format "[%s] " status)
'face (pcase status
("A" 'simple-git-staged-face)
("D" 'simple-git-unstaged-face)
(_ 'simple-git-untracked-face)))
file "\n")
(put-text-property line-start (point) 'simple-git-commit-file file)
(put-text-property line-start (point) 'simple-git-commit-hash hash)))))))))
(with-current-buffer buf
(simple-git-commit-detail-mode)
(goto-char (point-min)))
(display-buffer buf))
(message "No commit at point"))))
;;;###autoload
(defun simple-git-file-history ()
"Show commit history for the current file."
(interactive)
(unless (simple-git--in-repo-p)
(user-error "Not in a Git repository"))
(unless buffer-file-name
(user-error "Buffer is not visiting a file"))
(let* ((root (simple-git--root))
(file (file-relative-name buffer-file-name root))
(buf (get-buffer-create (format "*simple-git-history:%s*" (file-name-nondirectory file)))))
(with-current-buffer buf
(simple-git-file-history-mode)
(setq simple-git--file-history-file file)
(setq simple-git--file-history-root root)
(simple-git-file-history-refresh))
(pop-to-buffer buf)))
;;; ============================================================================
;;; Branch Graph Mode
;;; ============================================================================
(defvar simple-git-branch-graph-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'simple-git-branch-graph-show-commit)
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "n") #'next-line)
(define-key map (kbd "p") #'previous-line)
(define-key map (kbd "g") #'simple-git-branch-graph-refresh)
map)
"Keymap for `simple-git-branch-graph-mode'.")
(define-derived-mode simple-git-branch-graph-mode special-mode "SimpleGit:Graph"
"Major mode for viewing branch graph."
(setq buffer-read-only t)
(setq truncate-lines t))
(defface simple-git-graph-branch-1
'((t :foreground "#e06c75"))
"Face for branch 1 in graph.")
(defface simple-git-graph-branch-2
'((t :foreground "#98c379"))
"Face for branch 2 in graph.")
(defface simple-git-graph-branch-3
'((t :foreground "#61afef"))
"Face for branch 3 in graph.")
(defface simple-git-graph-branch-4
'((t :foreground "#c678dd"))
"Face for branch 4 in graph.")
(defface simple-git-graph-branch-5
'((t :foreground "#e5c07b"))
"Face for branch 5 in graph.")
(defface simple-git-graph-branch-6
'((t :foreground "#56b6c2"))
"Face for branch 6 in graph.")
(defvar simple-git-branch-graph-count 100
"Number of commits to show in branch graph.")
(defun simple-git--graph-face-for-column (col)
"Return face for graph column COL."
(let ((faces [simple-git-graph-branch-1
simple-git-graph-branch-2
simple-git-graph-branch-3
simple-git-graph-branch-4
simple-git-graph-branch-5
simple-git-graph-branch-6]))
(aref faces (mod col (length faces)))))
(defun simple-git--colorize-graph (graph-str)
"Apply colors to GRAPH-STR based on column position."
(let ((result "")
(col 0)
(i 0))
(while (< i (length graph-str))
(let ((char (aref graph-str i)))
(cond
((memq char '(?* ?| ?/ ?\\))
(setq result (concat result
(propertize (char-to-string char)
'face (simple-git--graph-face-for-column col))))
(when (memq char '(?* ?|))
(setq col (1+ col))))
((eq char ?\s)
(setq result (concat result " ")))
(t
(setq result (concat result (char-to-string char))))))
(setq i (1+ i)))
result))
(defun simple-git-branch-graph-refresh ()
"Refresh the branch graph buffer."
(interactive)
(when (eq major-mode 'simple-git-branch-graph-mode)
(let ((inhibit-read-only t)
(pos (point))
(default-directory (simple-git--root)))
(erase-buffer)
(insert (propertize "Branch Graph" 'face 'simple-git-header-face) "\n\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show commit "
(propertize "g" 'face 'font-lock-keyword-face) " refresh "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
;; Get graph with commit info
(let* ((output (simple-git--run "log" "--all" "--graph"
(format "-n%d" simple-git-branch-graph-count)
"--pretty=format:%h|%an|%ar|%s|%d"))
(lines (split-string output "\n")))
(dolist (line lines)
(if (string-match "^\\([*| /\\\\]+\\)\\([a-f0-9]+\\)|\\([^|]*\\)|\\([^|]*\\)|\\([^|]*\\)|\\(.*\\)$" line)
;; Line with commit info
(let* ((graph (match-string 1 line))
(hash (match-string 2 line))
(author (match-string 3 line))
(date (match-string 4 line))
(subject (match-string 5 line))
(refs (match-string 6 line))
(line-start (point)))
(insert (simple-git--colorize-graph graph))
(insert (propertize hash 'face 'simple-git-commit-hash-face) " ")
;; Show branch/tag refs if present
(when (and refs (not (string-empty-p (string-trim refs))))
(insert (propertize (string-trim refs) 'face 'font-lock-keyword-face) " "))
(insert (propertize (truncate-string-to-width (or author "") 15)
'face 'simple-git-commit-author-face) " ")
(insert (or subject "") "\n")
(put-text-property line-start (point) 'simple-git-commit-hash hash))
;; Graph-only line (no commit)
(when (string-match "^\\([*| /\\\\]+\\)$" line)
(insert (simple-git--colorize-graph (match-string 1 line)) "\n")))))
(goto-char (min pos (point-max))))))
(defun simple-git-branch-graph-show-commit ()
"Show commit details for commit at point."
(interactive)
(when-let ((hash (get-text-property (line-beginning-position) 'simple-git-commit-hash)))
(let ((buf (get-buffer-create "*simple-git-commit-detail*"))
(root (simple-git--root)))
(with-current-buffer buf
(let ((inhibit-read-only t)
(default-directory root))
(erase-buffer)
;; Get commit info
(let* ((info (simple-git--run "show" "--no-patch"
"--format=%h%n%an%n%ar%n%s%n%b" hash))
(lines (split-string info "\n"))
(short-hash (nth 0 lines))
(author (nth 1 lines))
(date (nth 2 lines))
(subject (nth 3 lines))
(body (string-trim (mapconcat #'identity (nthcdr 4 lines) "\n"))))
;; Header
(insert (propertize "Commit: " 'face 'simple-git-header-face)
(propertize short-hash 'face 'simple-git-commit-hash-face) "\n")
(insert (propertize "Author: " 'face 'simple-git-header-face)
(propertize author 'face 'simple-git-commit-author-face) "\n")
(insert (propertize "Date: " 'face 'simple-git-header-face)
(propertize date 'face 'simple-git-commit-date-face) "\n\n")
(insert (propertize subject 'face 'bold) "\n")
(when (not (string-empty-p body))
(insert "\n" body "\n"))
(insert "\n")
(insert "Commands: "
(propertize "RET" 'face 'font-lock-keyword-face) " show diff "
(propertize "v" 'face 'font-lock-keyword-face) "iew file at this point "
(propertize "q" 'face 'font-lock-keyword-face) "uit\n\n")
;; Get list of changed files
(let* ((files-output (simple-git--run "show" "--name-status" "--format=" hash))
(file-lines (split-string files-output "\n" t)))
(insert (propertize "Changed files:\n" 'face 'simple-git-header-face))
(dolist (file-line file-lines)
(when (string-match "^\\([AMDRT]\\)\t\\(.+\\)$" file-line)
(let ((status (match-string 1 file-line))
(file (match-string 2 file-line)))
(let ((line-start (point)))
(insert " "
(propertize (format "[%s] " status)
'face (pcase status
("A" 'simple-git-staged-face)
("D" 'simple-git-unstaged-face)
(_ 'simple-git-untracked-face)))
file "\n")
(put-text-property line-start (point) 'simple-git-commit-file file)
(put-text-property line-start (point) 'simple-git-commit-hash hash)))))))))
(with-current-buffer buf
(simple-git-commit-detail-mode)
(goto-char (point-min)))
(display-buffer buf))))
;;;###autoload
(defun simple-git-branch-graph ()
"Show branch graph visualization."
(interactive)
(unless (simple-git--in-repo-p)
(user-error "Not in a Git repository"))
(let ((buf (get-buffer-create "*simple-git-graph*")))
(with-current-buffer buf
(simple-git-branch-graph-mode)
(simple-git-branch-graph-refresh))
(pop-to-buffer buf)))
;;; ============================================================================
;;; Entry Point
;;; ============================================================================
;;;###autoload
(defun simple-git-status ()
"Show Git status."
(interactive)
(unless (simple-git--in-repo-p)
(user-error "Not in a Git repository"))
(let ((buf (get-buffer-create "*simple-git-status*")))
(with-current-buffer buf
(simple-git-status-mode)
(simple-git-status-refresh))
(pop-to-buffer buf)))
;;;###autoload
(defun simple-git ()
"Open simple-git status buffer."
(interactive)
(simple-git-status))
(provide 'simple-git)
;;; simple-git.el ends here