;;; 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