diff --git a/.emacs.d/init.el b/.emacs.d/init.el index 7fa5c18..e2349c8 100755 --- a/.emacs.d/init.el +++ b/.emacs.d/init.el @@ -38,9 +38,15 @@ ;; Search (require 'xah-find) +;; Git +(require 'simple-git) + ;; Debugging (require 'dape) +;; Emacs Debugging +(require 'command-log-mode) + ;;; ============================================================================ ;;; MODE ASSOCIATIONS ;;; ============================================================================ @@ -162,23 +168,23 @@ Start typing to search - LSP provides fuzzy matching." ;; Kill debug session before quitting Emacs (add-hook 'kill-emacs-hook (lambda () - (ignore-errors (dape-quit)) - ;; Also kill any lingering dlv processes - (dolist (proc (process-list)) - (when (and (process-live-p proc) - (string-match-p "\\(dape\\|dlv\\)" (process-name proc))) - (ignore-errors - (let ((pid (process-id proc))) - (when pid (my-kill-process-tree pid))) - (delete-process proc)))))) + (ignore-errors (dape-quit)))) (defun my-dape-start-or-continue () "Start debugging or continue if already in a debug session. -If stopped at a breakpoint, continue. Otherwise start a new debug session." +If stopped at a breakpoint, continue. If running, do nothing. +If no session active, start a new debug session." (interactive) - (if-let ((conn (dape--live-connection 'stopped t))) - (dape-continue conn) - (call-interactively #'dape))) + (cond + ;; Stopped at breakpoint - continue + ((dape--live-connection 'stopped t) + (dape-continue (dape--live-connection 'stopped t))) + ;; Session active but running - do nothing + ((dape--live-connection 'parent t) + (message "Debug session already running")) + ;; No session - start new one + (t + (call-interactively #'dape)))) ;;; ============================================================================ ;;; FLYMAKE & DIAGNOSTICS @@ -344,7 +350,7 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." (setq compilation-scroll-output -1) (setq compilation-save-buffers-predicate 'ignore) -(defvar my-bottom-panel-buffers '("\\*compilation\\*" "\\*xref\\*" "\\*Flymake diagnostics.*\\*" "\\*grep\\*") +(defvar my-bottom-panel-buffers '("\\*compilation\\*" "\\*xref\\*" "\\*Flymake diagnostics.*\\*" "\\*grep\\*" "\\*simple-git.*\\*") "List of buffer name patterns for bottom panel.") (defun my-bottom-panel-buffer-p (buf) @@ -379,21 +385,62 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." '("\\*Flymake diagnostics.*\\*" (my-display-in-bottom-panel) (side . bottom) (slot . 1) (window-height . 0.25))) (add-to-list 'display-buffer-alist '("\\*grep\\*" (my-display-in-bottom-panel) (side . bottom) (slot . 1) (window-height . 0.25))) +(add-to-list 'display-buffer-alist + '("\\*simple-git.*\\*" (my-display-in-bottom-panel) (side . bottom) (slot . 1) (window-height . 0.25))) + +(defun my-dape-panels-visible-p () + "Return non-nil if any dape panel is currently visible." + (or (get-buffer-window "*dape-repl*") + (cl-some #'get-buffer-window + (seq-filter (lambda (buf) + (string-prefix-p "*dape-info" (buffer-name buf))) + (buffer-list))))) + +(defun my-close-dape-panels () + "Close all dape panels." + ;; Close dape-repl + (when-let ((win (get-buffer-window "*dape-repl*"))) + (delete-window win)) + ;; Close dape-info buffers + (dolist (buf (buffer-list)) + (when (string-prefix-p "*dape-info" (buffer-name buf)) + (when-let ((win (get-buffer-window buf))) + (delete-window win))))) + +(defun my-open-dape-panels () + "Open dape panels if debugging is active." + (when (dape--live-connection 'parent t) + (dape-info) + (when (get-buffer "*dape-repl*") + (dape-repl)))) (defun my-bottom-panel-toggle () - "Toggle the bottom panel. Close if visible, open if hidden." + "Smart toggle for bottom panel and dape UI. +If any panel is visible, close all. If none visible, open all." (interactive) - (let ((panel-window (my-get-bottom-panel-window))) - (if panel-window - (delete-window panel-window) - (let ((matching-buffers (seq-filter - (lambda (buf) - (seq-some (lambda (pat) (string-match-p pat (buffer-name buf))) - my-bottom-panel-buffers)) - (buffer-list)))) - (if matching-buffers - (my-display-in-bottom-panel (car matching-buffers) '((window-height . 0.25))) - (message "No bottom panel buffers open.")))))) + (let ((bottom-visible (my-get-bottom-panel-window)) + (dape-visible (my-dape-panels-visible-p)) + (dape-active (dape--live-connection 'parent t))) + (if (or bottom-visible dape-visible) + ;; Something is visible - close everything + (progn + (when bottom-visible + (delete-window bottom-visible)) + (when dape-visible + (my-close-dape-panels))) + ;; Nothing visible - open everything + (progn + ;; Open dape panels if debugging + (when dape-active + (my-open-dape-panels)) + ;; Open bottom panel if available + (let ((matching-buffers (seq-filter + (lambda (buf) + (seq-some (lambda (pat) (string-match-p pat (buffer-name buf))) + my-bottom-panel-buffers)) + (buffer-list)))) + (when matching-buffers + (my-display-in-bottom-panel (car matching-buffers) '((window-height . 0.25))))))))) ;;; ============================================================================ ;;; BACKUP & AUTOSAVE SETTINGS @@ -415,26 +462,27 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." ;;; PROCESS MANAGEMENT ;;; ============================================================================ -(defun my-kill-process-tree (pid) - "Kill PID and all its descendant processes." +(defun my-kill-process-tree (pid &optional kill-root) + "Kill all descendant processes of PID. Also kill PID itself if KILL-ROOT is non-nil." (let ((children (split-string (shell-command-to-string (format "pgrep -P %d 2>/dev/null" pid)) "\n" t))) (dolist (child children) (when (string-match "^[0-9]+$" child) - (my-kill-process-tree (string-to-number child))))) - (ignore-errors (call-process "kill" nil nil nil "-9" (number-to-string pid)))) + (my-kill-process-tree (string-to-number child) t)))) ; always kill descendants + (when kill-root + (ignore-errors (call-process "kill" nil nil nil "-9" (number-to-string pid))))) ;; Ensure all subprocesses are killed when Emacs exits (add-hook 'kill-emacs-hook (lambda () + ;; Kill all child processes of Emacs (and their children) + (my-kill-process-tree (emacs-pid)) + ;; Also clean up via Emacs process list (dolist (proc (process-list)) (when (process-live-p proc) - (let ((pid (process-id proc))) - (when pid - (my-kill-process-tree pid))) - (set-process-query-on-exit-flag proc t) + (set-process-query-on-exit-flag proc nil) (ignore-errors (delete-process proc)))))) ;; Kill terminal buffer when process exits @@ -468,6 +516,7 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." (global-set-key (kbd "C-n") (lambda () (interactive) (switch-to-buffer (generate-new-buffer "untitled")))) (global-set-key (kbd "C-o") 'find-file) (global-set-key (kbd "C-p") 'project-find-file) +(global-set-key (kbd "C-S-p") 'execute-extended-command) (global-set-key (kbd "C-3") 'switch-to-buffer) (global-set-key (kbd "C-4") 'find-file) (global-set-key (kbd "C-q") 'save-buffers-kill-terminal) @@ -493,6 +542,7 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." ;; --- Clipboard & Kill Ring (CUA-style) --- (global-set-key (kbd "C-z") 'undo) +(global-set-key (kbd "C-S-z") 'undo-redo) (global-set-key (kbd "C-v") 'clipboard-yank) (global-set-key (kbd "C-c") 'my-copy) (global-set-key (kbd "C-x") 'my-cut) @@ -557,10 +607,6 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." (global-set-key (kbd "") 'my-file-manager-command) (global-set-key (kbd "") 'my-terminal-emulator-command) -;; --- Project Management --- -(global-set-key (kbd "") 'project-switch-project) -(global-set-key (kbd "C-S-p") 'execute-extended-command) - ;; --- Themes --- (global-set-key (kbd "") 'my-select-theme) @@ -593,6 +639,10 @@ If stopped at a breakpoint, continue. Otherwise start a new debug session." (global-set-key (kbd "C-S-r") 'my-toggle-macro-recording) (global-set-key (kbd "C-M-r") 'my-call-macro) +;; --- Git --- +(global-set-key (kbd "") 'simple-git-status) +(global-set-key (kbd "C-") 'simple-git-file-history) + ;; --- Misc --- (global-set-key (kbd "C-e") 'my-select-inside-parens) (global-set-key (kbd "C-y") 'my-copy-path-with-line) @@ -1150,31 +1200,38 @@ Does not copy to kill ring." ;;; ============================================================================ (add-to-list 'custom-theme-load-path "~/.emacs.d/themes/") -(defvar my-current-theme 'bedroom "Currently active theme.") +(defcustom my-current-theme 'bedroom + "Currently active theme. Saved automatically when changed." + :type 'symbol + :group 'my-settings) + +(defun my-apply-builtin-theme (theme) + "Apply a built-in color theme (not a real Emacs theme)." + (pcase theme + ('default + (set-foreground-color "black") + (set-background-color "white")) + ('default-dark + (set-foreground-color "white") + (set-background-color "black")) + ('xah + (set-foreground-color "black") + (set-background-color "honeydew")))) (defun my-select-theme () - "Select and load a theme from all available themes." + "Select and load a theme from all available themes. Saves choice for next session." (interactive) (let* ((themes (append '("default" "default-dark" "xah") (mapcar #'symbol-name (custom-available-themes)))) - (choice (completing-read "Theme: " themes nil t))) - (when my-current-theme + (choice (completing-read "Theme: " themes nil t)) + (theme-sym (intern choice))) + ;; Disable current theme if it's a real theme + (when (and my-current-theme + (not (memq my-current-theme '(default default-dark xah)))) (disable-theme my-current-theme)) - (cond - ((string= choice "default") - (set-foreground-color "black") - (set-background-color "white") - (setq my-current-theme nil)) - ((string= choice "default-dark") - (set-foreground-color "white") - (set-background-color "black") - (setq my-current-theme nil)) - ((string= choice "xah") - (set-foreground-color "black") - (set-background-color "honeydew") - (setq my-current-theme nil)) - (t - (setq my-current-theme (intern choice)) - (load-theme my-current-theme t))))) + (if (memq theme-sym '(default default-dark xah)) + (my-apply-builtin-theme theme-sym) + (load-theme theme-sym t)) + (customize-save-variable 'my-current-theme theme-sym))) ;;; ============================================================================ ;;; CUSTOM FUNCTIONS - Zoom @@ -1208,7 +1265,7 @@ Does not copy to kill ring." (when proc (let ((pid (process-id proc))) (when pid - (my-kill-process-tree pid))) + (my-kill-process-tree pid t))) (set-process-query-on-exit-flag proc nil))) (with-current-buffer buf (set-buffer-modified-p nil)) @@ -1374,12 +1431,6 @@ Use in `isearch-mode-end-hook'." ;; (set-face-attribute 'default nil :font "Consolas-15") -;; Disable current theme before loading to prevent stacking on config reload -(when my-current-theme - (disable-theme my-current-theme)) -(setq my-current-theme 'bedroom) -(load-theme 'bedroom t) - ;;; ============================================================================ ;;; CUSTOM ;;; ============================================================================ @@ -1389,6 +1440,7 @@ Use in `isearch-mode-end-hook'." ;; If you edit it by hand, you could mess it up, so be careful. ;; Your init file should contain only one such instance. ;; If there is more than one, they won't work right. + '(my-current-theme 'valigo) '(safe-local-variable-directories '("/Users/mta/projects/cdrateline.com_2.0/"))) (custom-set-faces ;; custom-set-faces was added by Custom. @@ -1397,4 +1449,14 @@ Use in `isearch-mode-end-hook'." ;; If there is more than one, they won't work right. ) +;;; ============================================================================ +;;; LOAD THEME (must be after custom-set-variables) +;;; ============================================================================ + +(mapc #'disable-theme custom-enabled-themes) +(when my-current-theme + (if (memq my-current-theme '(default default-dark xah)) + (my-apply-builtin-theme my-current-theme) + (load-theme my-current-theme t))) + ;;; init.el ends here diff --git a/.emacs.d/lisp/command-log-mode.el b/.emacs.d/lisp/command-log-mode.el new file mode 100644 index 0000000..34ec546 --- /dev/null +++ b/.emacs.d/lisp/command-log-mode.el @@ -0,0 +1,322 @@ +;;; command-log-mode.el --- log keyboard commands to buffer + +;; homepage: https://github.com/lewang/command-log-mode + +;; Copyright (C) 2013 Nic Ferrier +;; Copyright (C) 2012 Le Wang +;; Copyright (C) 2004 Free Software Foundation, Inc. + +;; Author: Michael Weber +;; Keywords: help +;; Initial-version: <2004-10-07 11:41:28 michaelw> +;; Time-stamp: <2004-11-06 17:08:11 michaelw> + +;; This file is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 2, or (at your option) +;; any later version. + +;; This file is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs; see the file COPYING. If not, write to +;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330, +;; Boston, MA 02111-1307, USA. + +;;; Commentary: + +;; This add-on can be used to demo Emacs to an audience. When +;; activated, keystrokes get logged into a designated buffer, along +;; with the command bound to them. + +;; To enable, use e.g.: +;; +;; (require 'command-log-mode) +;; (add-hook 'LaTeX-mode-hook 'command-log-mode) +;; +;; To see the log buffer, call M-x clm/open-command-log-buffer. + +;; The key strokes in the log are decorated with ISO9601 timestamps on +;; the property `:time' so if you want to convert the log for +;; screencasting purposes you could use the time stamp as a key into +;; the video beginning. + +;;; Code: + +(eval-when-compile (require 'cl)) + +(defvar clm/log-text t + "A non-nil setting means text will be saved to the command log.") + +(defvar clm/log-repeat nil + "A nil setting means repetitions of the same command are merged into the single log line.") + +(defvar clm/recent-history-string "" + "This string will hold recently typed text.") + +(defun clm/recent-history () + (setq clm/recent-history-string + (concat clm/recent-history-string + (buffer-substring-no-properties (- (point) 1) (point))))) + +(add-hook 'post-self-insert-hook 'clm/recent-history) + +(defun clm/zap-recent-history () + (unless (or (member this-original-command + clm/log-command-exceptions*) + (eq this-original-command #'self-insert-command)) + (setq clm/recent-history-string ""))) + +(add-hook 'post-command-hook 'clm/zap-recent-history) + +(defvar clm/time-string "%Y-%m-%dT%H:%M:%S" + "The string sent to `format-time-string' when command time is logged.") + +(defvar clm/logging-dir "~/log/" + "Directory in which to store files containing logged commands.") + +(defvar clm/log-command-exceptions* + '(nil self-insert-command backward-char forward-char + delete-char delete-backward-char backward-delete-char + backward-delete-char-untabify + universal-argument universal-argument-other-key + universal-argument-minus universal-argument-more + beginning-of-line end-of-line recenter + move-end-of-line move-beginning-of-line + handle-switch-frame + newline previous-line next-line) + "A list commands which should not be logged, despite logging being enabled. +Frequently used non-interesting commands (like cursor movements) should be put here.") + +(defvar clm/command-log-buffer nil + "Reference of the currenly used buffer to display logged commands.") +(defvar clm/command-repetitions 0 + "Count of how often the last keyboard commands has been repeated.") +(defvar clm/last-keyboard-command nil + "Last logged keyboard command.") + + +(defvar clm/log-command-indentation 11 + "*Indentation of commands in command log buffer.") + +(defgroup command-log nil + "Customization for the command log.") + +(defcustom command-log-mode-auto-show nil + "Show the command-log window or frame automatically." + :group 'command-log + :type 'boolean) + +(defcustom command-log-mode-window-size 40 + "The size of the command-log window." + :group 'command-log + :type 'integer) + +(defcustom command-log-mode-window-font-size 2 + "The font-size of the command-log window." + :group 'command-log + :type 'integer) + +(defcustom command-log-mode-key-binding-open-log "C-c o" + "The key binding used to toggle the log window." + :group 'command-log + :type '(radio + (const :tag "No key" nil) + (key-sequence "C-c o"))) ;; this is not right though it works for kbd + +(defcustom command-log-mode-open-log-turns-on-mode nil + "Does opening the command log turn on the mode?" + :group 'command-log + :type 'boolean) + +(defcustom command-log-mode-is-global nil + "Does turning on command-log-mode happen globally?" + :group 'command-log + :type 'boolean) + +;;;###autoload +(define-minor-mode command-log-mode + "Toggle keyboard command logging." + :init-value nil + :lighter " command-log" + :keymap nil + (if command-log-mode + (when (and + command-log-mode-auto-show + (not (get-buffer-window clm/command-log-buffer))) + (clm/open-command-log-buffer)) + ;; We can close the window though + (clm/close-command-log-buffer))) + +(define-global-minor-mode global-command-log-mode command-log-mode + command-log-mode) + +(defun clm/buffer-log-command-p (cmd &optional buffer) + "Determines whether keyboard command CMD should be logged. +If non-nil, BUFFER specifies the buffer used to determine whether CMD should be logged. +If BUFFER is nil, the current buffer is assumed." + (let ((val (if buffer + (buffer-local-value command-log-mode buffer) + command-log-mode))) + (and (not (null val)) + (null (member cmd clm/log-command-exceptions*))))) + +(defmacro clm/save-command-environment (&rest body) + (declare (indent 0)) + `(let ((deactivate-mark nil) ; do not deactivate mark in transient + ; mark mode + ;; do not let random commands scribble over + ;; {THIS,LAST}-COMMAND + (this-command this-command) + (last-command last-command)) + ,@body)) + +(defun clm/open-command-log-buffer (&optional arg) + "Opens (and creates, if non-existant) a buffer used for logging keyboard commands. +If ARG is Non-nil, the existing command log buffer is cleared." + (interactive "P") + (with-current-buffer + (setq clm/command-log-buffer + (get-buffer-create " *command-log*")) + (text-scale-set 1)) + (when arg + (with-current-buffer clm/command-log-buffer + (erase-buffer))) + (let ((new-win (split-window-horizontally + (- 0 command-log-mode-window-size)))) + (set-window-buffer new-win clm/command-log-buffer) + (set-window-dedicated-p new-win t))) + +(defun clm/close-command-log-buffer () + "Close the command log window." + (interactive) + (with-current-buffer + (setq clm/command-log-buffer + (get-buffer-create " *command-log*")) + (let ((win (get-buffer-window (current-buffer)))) + (when (windowp win) + (delete-window win))))) + +;;;###autoload +(defun clm/toggle-command-log-buffer (&optional arg) + "Toggle the command log showing or not." + (interactive "P") + (when (and command-log-mode-open-log-turns-on-mode + (not command-log-mode)) + (if command-log-mode-is-global + (global-command-log-mode) + (command-log-mode))) + (with-current-buffer + (setq clm/command-log-buffer + (get-buffer-create " *command-log*")) + (let ((win (get-buffer-window (current-buffer)))) + (if (windowp win) + (clm/close-command-log-buffer) + ;; Else open the window + (clm/open-command-log-buffer arg))))) + +(defun clm/scroll-buffer-window (buffer &optional move-fn) + "Updates `point' of windows containing BUFFER according to MOVE-FN. +If non-nil, MOVE-FN is called on every window which displays BUFFER. +If nil, MOVE-FN defaults to scrolling to the bottom, making the last line visible. + +Scrolling up can be accomplished with: +\(clm/scroll-buffer-window buf (lambda () (goto-char (point-min)))) +" + (let ((selected (selected-window)) + (point-mover (or move-fn + (function (lambda () (goto-char (point-max))))))) + (walk-windows (function (lambda (window) + (when (eq (window-buffer window) buffer) + (select-window window) + (funcall point-mover) + (select-window selected)))) + nil t))) + +(defmacro clm/with-command-log-buffer (&rest body) + (declare (indent 0)) + `(when (and (not (null clm/command-log-buffer)) + (buffer-name clm/command-log-buffer)) + (with-current-buffer clm/command-log-buffer + ,@body))) + +(defun clm/log-command (&optional cmd) + "Hook into `pre-command-hook' to intercept command activation." + (clm/save-command-environment + (setq cmd (or cmd this-command)) + (when (clm/buffer-log-command-p cmd) + (clm/with-command-log-buffer + (let ((current (current-buffer))) + (goto-char (point-max)) + (cond ((and (not clm/log-repeat) (eq cmd clm/last-keyboard-command)) + (incf clm/command-repetitions) + (save-match-data + (when (and (> clm/command-repetitions 1) + (search-backward "[" (line-beginning-position -1) t)) + (delete-region (point) (line-end-position)))) + (backward-char) ; skip over either ?\newline or ?\space before ?\[ + (insert " [") + (princ (1+ clm/command-repetitions) current) + (insert " times]")) + (t ;; (message "last cmd: %s cur: %s" last-command cmd) + ;; showing accumulated text with interleaved key presses isn't very useful + (when (and clm/log-text (not clm/log-repeat)) + (if (eq clm/last-keyboard-command 'self-insert-command) + (insert "[text: " clm/recent-history-string "]\n"))) + (setq clm/command-repetitions 0) + (insert + (propertize + (key-description (this-command-keys)) + :time (format-time-string clm/time-string (current-time)))) + (when (>= (current-column) clm/log-command-indentation) + (newline)) + (move-to-column clm/log-command-indentation t) + (princ (if (byte-code-function-p cmd) "" cmd) current) + (newline) + (setq clm/last-keyboard-command cmd))) + (clm/scroll-buffer-window current)))))) + +(defun clm/command-log-clear () + "Clear the command log buffer." + (interactive) + (with-current-buffer clm/command-log-buffer + (erase-buffer))) + +(defun clm/save-log-line (start end) + "Helper function for `clm/save-command-log' to export text properties." + (save-excursion + (goto-char start) + (let ((time (get-text-property (point) :time))) + (if time + (list (cons start (if time + (concat "[" (get-text-property (point) :time) "] ") + ""))))))) + +(defun clm/save-command-log () + "Save commands to today's log. +Clears the command log buffer after saving." + (interactive) + (save-window-excursion + (set-buffer (get-buffer " *command-log*")) + (goto-char (point-min)) + (let ((now (format-time-string "%Y-%m-%d")) + (write-region-annotate-functions '(clm/save-log-line))) + (while (and (re-search-forward "^.*" nil t) + (not (eobp))) + (append-to-file (line-beginning-position) (1+ (line-end-position)) (concat clm/logging-dir now)))) + (clm/command-log-clear))) + +(add-hook 'pre-command-hook 'clm/log-command) + +(eval-after-load 'command-log-mode + '(when command-log-mode-key-binding-open-log + (global-set-key + (kbd command-log-mode-key-binding-open-log) + 'clm/toggle-command-log-buffer))) + +(provide 'command-log-mode) + +;;; command-log-mode.el ends here diff --git a/.emacs.d/lisp/simple-git.el b/.emacs.d/lisp/simple-git.el new file mode 100644 index 0000000..81b576f --- /dev/null +++ b/.emacs.d/lisp/simple-git.el @@ -0,0 +1,1151 @@ +;;; 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