├── .gitignore ├── magithub.el └── magithub.texi /.gitignore: -------------------------------------------------------------------------------- 1 | /magithub.* 2 | !/magithub.texi 3 | -------------------------------------------------------------------------------- /magithub.el: -------------------------------------------------------------------------------- 1 | ;;; magithub.el --- Magit extensions for using GitHub 2 | 3 | ;; Copyright (c) 2010 Nathan Weizenbaum 4 | ;; Licensed under the same terms as Emacs. 5 | 6 | ;; Author: Nathan Weizenbaum 7 | ;; URL: http://github.com/nex3/magithub 8 | ;; Version: 0.2 9 | ;; Created: 2010-06-06 10 | ;; By: Nathan Weizenbaum 11 | ;; Keywords: git, github, magit 12 | ;; Package-Requires: ((magit "0.8") (json "1.2")) 13 | 14 | ;;; Commentary: 15 | 16 | ;; This package does two things. First, it extends Magit's UI with 17 | ;; assorted GitHub-related functionality, similar to the github-gem 18 | ;; tool (http://github.com/defunkt/github-gem). Second, it uses 19 | ;; Magit's excellent Git library to build an Elisp library for 20 | ;; interfacing with GitHub's API. 21 | 22 | (require 'magit) 23 | (require 'url) 24 | (require 'json) 25 | (require 'crm) 26 | (eval-when-compile (require 'cl)) 27 | 28 | ;;; Customizables 29 | 30 | (defcustom magithub-use-ssl nil 31 | "If non-nil, access GitHub via HTTPS. 32 | This is more secure, but slower." 33 | :group 'magithub 34 | :type 'boolean) 35 | 36 | (defcustom magithub-view-gist t 37 | "Whether or not to open new Gists in the browser." 38 | :group 'magithub 39 | :type 'boolean) 40 | 41 | (defcustom magithub-message-mode-hook nil 42 | "Hook run by `magithub-message-mode'." 43 | :group 'magithub 44 | :type 'hook) 45 | 46 | (defcustom magithub-message-confirm-cancellation magit-log-edit-confirm-cancellation 47 | "If non-nil, confirm when cancelling the editing of a `magithub-message-mode' buffer." 48 | :group 'magithub 49 | :type 'boolean) 50 | 51 | ;;; Variables 52 | 53 | (defvar magithub-api-base "https://github.com/api/v2/json/" 54 | "The base URL for accessing the GitHub API.") 55 | 56 | (defvar magithub-github-url "https://github.com/" 57 | "The URL for the main GitHub site. 58 | 59 | This is used for some calls that aren't supported by the official API.") 60 | 61 | (defvar magithub-gist-url "http://gist.github.com/" 62 | "The URL for the Gist site.") 63 | 64 | (defvar magithub-request-data nil 65 | "An assoc list of parameter names to values. 66 | 67 | This is meant to be dynamically bound around `magithub-retrieve' 68 | and `magithub-retrieve-synchronously'.") 69 | 70 | (defvar magithub-parse-response t 71 | "Whether to parse responses from GitHub as JSON. 72 | Used by `magithub-retrieve' and `magithub-retrieve-synchronously'. 73 | This should only ever be `let'-bound, not set outright.") 74 | 75 | (defvar magithub-users-history nil 76 | "A list of users selected via `magithub-read-user'.") 77 | 78 | (defvar magithub-repos-history nil 79 | "A list of repos selected via `magithub-read-repo'.") 80 | 81 | (defvar magithub--repo-obj-cache (make-hash-table :test 'equal) 82 | "A hash from (USERNAME . REPONAME) to decoded JSON repo objects (plists). 83 | This caches the result of `magithub-repo-obj' and 84 | `magithub-cached-repo-obj'.") 85 | 86 | 87 | ;;; Utilities 88 | 89 | (defun magithub--remove-if (predicate seq) 90 | "Remove all items satisfying PREDICATE from SEQ. 91 | Like `remove-if', but without the cl runtime dependency." 92 | (loop for el being the elements of seq 93 | if (not (funcall predicate el)) collect el into els 94 | finally return els)) 95 | 96 | (defun magithub--position (item seq) 97 | "Return the index of ITEM in SEQ. 98 | Like `position', but without the cl runtime dependency. 99 | 100 | Comparison is done with `eq'." 101 | (loop for el in seq until (eq el item) count t)) 102 | 103 | (defun magithub--cache-function (fn) 104 | "Return a lambda that will run FN but cache its return values. 105 | The cache is a very naive assoc from arguments to returns. 106 | The cache will only last as long as the lambda does. 107 | 108 | FN may call magithub--use-cache, which will use a pre-cached 109 | value if available or recursively call FN if not." 110 | (lexical-let ((fn fn) cache cache-fn) 111 | (setq cache-fn 112 | (lambda (&rest args) 113 | (let ((cached (assoc args cache))) 114 | (if cached (cdr cached) 115 | (flet ((magithub--use-cache (&rest args) (apply cache-fn args))) 116 | (let ((val (apply fn args))) 117 | (push (cons args val) cache) 118 | val)))))))) 119 | 120 | (defun magithub-make-query-string (params) 121 | "Return a query string constructed from PARAMS. 122 | PARAMS is an assoc list of parameter names to values. 123 | 124 | Any parameters with a nil values are ignored." 125 | (replace-regexp-in-string 126 | "&+" "&" 127 | (mapconcat 128 | (lambda (param) 129 | (when (cdr param) 130 | (concat (url-hexify-string (car param)) "=" 131 | (url-hexify-string (cdr param))))) 132 | params "&"))) 133 | 134 | (defun magithub-parse-repo (repo) 135 | "Parse a REPO string of the form \"username/repo\". 136 | Return (USERNAME . REPO), or raise an error if the format is 137 | incorrect." 138 | (condition-case err 139 | (destructuring-bind (username repo) (split-string repo "/") 140 | (let ((trim-space 141 | (apply-partially 'replace-regexp-in-string 142 | "^ *\\(.*?\\) *$" "\\1"))) 143 | (cons (funcall trim-space username) 144 | (funcall trim-space repo)))) 145 | (wrong-number-of-arguments (error "Invalid GitHub repository %s" repo)))) 146 | 147 | (defun magithub-repo-url (username repo &optional sshp) 148 | "Return the repository URL for USERNAME/REPO. 149 | If SSHP is non-nil, return the SSH URL instead. Otherwise, 150 | return the HTTP URL." 151 | (format (if sshp "git@github.com:%s/%s.git" "http://github.com/%s/%s.git") 152 | username repo)) 153 | 154 | (defun magithub-remote-info (remote) 155 | "Return (USERNAME REPONAME SSHP) for the given REMOTE. 156 | Return nil if REMOTE isn't a GitHub remote. 157 | 158 | USERNAME is the owner of the repo, REPONAME is the name of the 159 | repo, and SSH is non-nil if it's checked out via SSH." 160 | (block nil 161 | (let ((url (magit-get "remote" remote "url"))) 162 | (unless url (return)) 163 | (when (string-match "\\(?:git\\|https?\\)://.*@?github\\.com/\\(.*?\\)/\\(.*\\)\.git" url) 164 | (return (list (match-string 1 url) (match-string 2 url) nil))) 165 | (when (string-match "git@github\\.com:\\(.*?\\)/\\(.*\\)\\.git" url) 166 | (return (list (match-string 1 url) (match-string 2 url) t))) 167 | (return)))) 168 | 169 | (defun magithub-remote-for-commit (commit) 170 | "Return the name of the remote that contains COMMIT. 171 | If no remote does, return nil. COMMIT should be the full SHA1 172 | commit hash. 173 | 174 | If origin contains the commit, it takes precedence. Otherwise 175 | the priority is nondeterministic." 176 | (flet ((name-rev (remote commit) 177 | (magit-git-string "name-rev" "--name-only" "--no-undefined" "--refs" 178 | ;; I'm not sure why the initial * is required, 179 | ;; but if it's not there this always returns nil 180 | (format "*remotes/%s/*" remote) commit))) 181 | (let ((remote (or (name-rev "origin" commit) (name-rev "*" commit)))) 182 | (when (and remote (string-match "^remotes/\\(.*?\\)/" remote)) 183 | (match-string 1 remote))))) 184 | 185 | (defun magithub-remote-info-for-commit (commit) 186 | "Return information about the GitHub repo for the remote that contains COMMIT. 187 | If no remote does, return nil. COMMIT should be the full SHA1 188 | commit hash. 189 | 190 | The information is of the form returned by `magithub-remote-info'. 191 | 192 | If origin contains the commit, it takes precedence. Otherwise 193 | the priority is nondeterministic." 194 | (let ((remote (magithub-remote-for-commit commit))) 195 | (when remote (magithub-remote-info remote)))) 196 | 197 | (defun magithub-branches-for-remote (remote) 198 | "Return a list of branches in REMOTE, as of the last fetch." 199 | (let ((lines (magit-git-lines "remote" "show" "-n" remote)) branches) 200 | (while (not (string-match-p "^ Remote branches:" (pop lines))) 201 | (unless lines (error "Unknown output from `git remote show'"))) 202 | (while (string-match "^ \\(.*\\)" (car lines)) 203 | (push (match-string 1 (pop lines)) branches)) 204 | branches)) 205 | 206 | (defun magithub-repo-relative-path () 207 | "Return the path to the current file relative to the repository root. 208 | Only works within `magithub-minor-mode'." 209 | (let ((filename buffer-file-name)) 210 | (with-current-buffer magithub-status-buffer 211 | (file-relative-name filename default-directory)))) 212 | 213 | (defun magithub-name-rev-for-remote (rev remote) 214 | "Return a human-readable name for REV that's valid in REMOTE. 215 | Like `magit-name-rev', but sanitizes things referring to remotes 216 | and errors out on local-only revs." 217 | (setq rev (magit-name-rev rev)) 218 | (if (and (string-match "^\\(remotes/\\)?\\(.*?\\)/\\(.*\\)" rev) 219 | (equal (match-string 2 rev) remote)) 220 | (match-string 3 rev) 221 | (unless (magithub-remote-contains-p remote rev) 222 | (error "Commit %s hasn't been pushed" 223 | (substring (magit-git-string "rev-parse" rev) 0 8))) 224 | (cond 225 | ;; Assume the GitHub repo will have all the same tags as we do, 226 | ;; since we can't actually check without performing an HTTP request. 227 | ((string-match "^tags/\\(.*\\)" rev) (match-string 1 rev)) 228 | ((and (not (string-match-p "^remotes/" rev)) 229 | (member rev (magithub-branches-for-remote remote)) 230 | (magithub-ref= rev (concat remote "/" rev))) 231 | rev) 232 | (t (magit-git-string "rev-parse" rev))))) 233 | 234 | (defun magithub-remotes-containing-ref (ref) 235 | "Return a list of remotes containing REF." 236 | (loop with remotes 237 | for line in (magit-git-lines "branch" "-r" "--contains" ref) 238 | if (and (string-match "^ *\\(.+?\\)/" line) 239 | (not (string= (match-string 1 line) (car remotes)))) 240 | do (push (match-string 1 line) remotes) 241 | finally return remotes)) 242 | 243 | (defun magithub-remote-contains-p (remote ref) 244 | "Return whether REF exists in REMOTE, in any branch. 245 | This does not fetch origin before determining existence, so it's 246 | possible that its result is based on stale data." 247 | (member remote (magithub-remotes-containing-ref ref))) 248 | 249 | (defun magithub-ref= (ref1 ref2) 250 | "Return whether REF1 refers to the same commit as REF2." 251 | (string= (magit-rev-parse ref1) (magit-rev-parse ref2))) 252 | 253 | 254 | ;;; Reading Input 255 | 256 | (defun magithub--lazy-completion-callback (fn &optional noarg) 257 | "Converts a simple string-listing FN into a lazy-loading completion callback. 258 | FN should take a string (the contents of the minibuffer) and 259 | return a list of strings (the candidates for completion). This 260 | method takes care of any caching and makes sure FN isn't called 261 | until completion needs to happen. 262 | 263 | If NOARG is non-nil, don't pass a string to FN." 264 | (lexical-let ((fn (magithub--cache-function fn)) (noarg noarg)) 265 | (lambda (string predicate allp) 266 | (let ((strs (if noarg (funcall fn) (funcall fn string)))) 267 | (if allp (all-completions string strs predicate) 268 | (try-completion string strs predicate)))))) 269 | 270 | (defun magithub-read-user (&optional prompt predicate require-match initial-input 271 | hist def inherit-input-method) 272 | "Read a GitHub username from the minibuffer with completion. 273 | 274 | PROMPT, PREDICATE, REQUIRE-MATCH, INITIAL-INPUT, HIST, DEF, and 275 | INHERIT-INPUT-METHOD work as in `completing-read'. PROMPT 276 | defaults to \"GitHub user: \". HIST defaults to 277 | 'magithub-users-history. 278 | 279 | WARNING: This function currently doesn't work fully, since 280 | GitHub's user search API only returns an apparently random subset 281 | of users." 282 | (setq hist (or hist 'magithub-users-history)) 283 | (completing-read (or prompt "GitHub user: ") 284 | (magithub--lazy-completion-callback 285 | (lambda (s) 286 | (mapcar (lambda (user) (plist-get user :name)) 287 | (magithub-user-search s)))) 288 | predicate require-match initial-input hist def inherit-input-method)) 289 | 290 | (defun magithub-read-repo-for-user (user &optional prompt predicate require-match 291 | initial-input hist def inherit-input-method) 292 | "Read a GitHub repository from the minibuffer with completion. 293 | USER is the owner of the repository. 294 | 295 | PROMPT, PREDICATE, REQUIRE-MATCH, INITIAL-INPUT, HIST, DEF, and 296 | INHERIT-INPUT-METHOD work as in `completing-read'. PROMPT 297 | defaults to \"GitHub repo: /\"." 298 | (lexical-let ((user user)) 299 | (completing-read (or prompt (concat "GitHub repo: " user "/")) 300 | (magithub--lazy-completion-callback 301 | (lambda () 302 | (mapcar (lambda (repo) (plist-get repo :name)) 303 | (magithub-repos-for-user user))) 304 | 'noarg) 305 | predicate require-match initial-input hist def 306 | inherit-input-method))) 307 | 308 | (defun magithub-read-repo (&optional prompt predicate require-match initial-input 309 | hist def inherit-input-method) 310 | "Read a GitHub user-repository pair with completion. 311 | Return (USERNAME . REPO), or nil if the user enters no input. 312 | 313 | PROMPT, PREDICATE, REQUIRE-MATCH, INITIAL-INPUT, HIST, DEF, and 314 | INHERIT-INPUT-METHOD work as in `completing-read'. PROMPT 315 | defaults to \"GitHub repo (user/repo): \". HIST defaults to 316 | 'magithub-repos-history. If REQUIRE-MATCH is non-nil and the 317 | user enters no input, raises an error. 318 | 319 | WARNING: This function currently doesn't work fully, since 320 | GitHub's user search API only returns an apparently random subset 321 | of users, and also has no way to search for users whose names 322 | begin with certain characters." 323 | (setq hist (or hist 'magithub-repos-history)) 324 | (let ((result (completing-read 325 | (or prompt "GitHub repo (user/repo): ") 326 | (magithub--lazy-completion-callback 'magithub--repo-completions) 327 | predicate require-match initial-input hist def inherit-input-method))) 328 | (if (string= result "") 329 | (when require-match (error "No repository given")) 330 | (magithub-parse-repo result)))) 331 | 332 | (defun magithub--repo-completions (string) 333 | "Try completing the given GitHub user/repository pair. 334 | STRING is the text already in the minibuffer, PREDICATE is a 335 | predicate that the string must satisfy." 336 | (destructuring-bind (username . rest) (split-string string "/") 337 | (if (not rest) ;; Need to complete username before we start completing repo 338 | (mapcar (lambda (user) (concat (plist-get user :name) "/")) 339 | (magithub-user-search username)) 340 | (if (not (string= (car rest) "")) 341 | (magithub--use-cache (concat username "/")) 342 | (mapcar (lambda (repo) (concat username "/" (plist-get repo :name))) 343 | (magithub-repos-for-user username)))))) 344 | 345 | (defun magithub-read-pull-request-recipients () 346 | "Read a list of recipients for a GitHub pull request." 347 | (let ((collabs (magithub-repo-parent-collaborators)) 348 | (network (magithub-repo-network))) 349 | (magithub--remove-if 350 | (lambda (s) (string= s "")) 351 | (completing-read-multiple 352 | "Send pull request to: " 353 | (mapcar (lambda (repo) (plist-get repo :owner)) (magithub-repo-network)) 354 | nil nil (concat (mapconcat 'identity collabs crm-separator) 355 | (if (= (length collabs) (length network)) "" crm-separator)))))) 356 | 357 | (defun magithub-read-untracked-fork () 358 | "Read the name of a fork of this repo that we aren't yet tracking. 359 | This will accept either a username or a username/repo pair, 360 | and return (USERNAME . REPONAME)." 361 | (let ((fork 362 | (completing-read 363 | "Track fork (user or user/repo): " 364 | (magithub--lazy-completion-callback 365 | (lambda () 366 | (mapcar (lambda (repo) (concat (plist-get repo :owner) "/" 367 | (plist-get repo :name))) 368 | (magithub-untracked-forks))) 369 | 'noarg) 370 | nil nil nil 'magithub-repos-history))) 371 | (cond 372 | ((string= fork "") (error "No fork given")) 373 | ((string-match "/" fork) (magithub-parse-repo fork)) 374 | (t (cons fork (magithub-repo-name)))))) 375 | 376 | 377 | ;;; Bindings 378 | 379 | (define-prefix-command 'magithub-prefix 'magithub-map) 380 | (define-key magithub-map (kbd "C") 'magithub-create-from-local) 381 | (define-key magithub-map (kbd "c") 'magithub-clone) 382 | (define-key magithub-map (kbd "f") 'magithub-fork-current) 383 | (define-key magithub-map (kbd "p") 'magithub-pull-request) 384 | (define-key magithub-map (kbd "t") 'magithub-track) 385 | (define-key magithub-map (kbd "g") 'magithub-gist-repo) 386 | (define-key magithub-map (kbd "S") 'magithub-toggle-ssh) 387 | (define-key magithub-map (kbd "b") 'magithub-browse-item) 388 | (define-key magit-mode-map (kbd "'") 'magithub-prefix) 389 | 390 | 391 | ;;; Requests 392 | 393 | (defun magit-request-url (path) 394 | "Return the full GitHub URL for the resource PATH. 395 | 396 | PATH can either be a string or a list of strings. 397 | In the latter case, they're URL-escaped and joined with \"/\". 398 | 399 | If `url-request-method' is GET, the returned URL will include 400 | `url-request-data' as the query string." 401 | (let ((url 402 | (concat magithub-api-base 403 | (if (stringp path) path (mapconcat 'url-hexify-string path "/")) 404 | (if (string= url-request-method "GET") 405 | (concat "?" url-request-data) 406 | "")))) 407 | (if magithub-use-ssl url 408 | (replace-regexp-in-string "^https" "http" url)))) 409 | 410 | (defmacro magithub-with-auth (&rest body) 411 | "Runs BODY with GitHub authorization info in `magithub-request-data'." 412 | (declare (indent 0)) 413 | (let ((auth (gensym))) 414 | `(let* ((,auth (magithub-auth-info)) 415 | (magithub-request-data (append (list 416 | (cons "login" (car ,auth)) 417 | (cons "token" (cdr ,auth))) 418 | magithub-request-data))) 419 | ,@body))) 420 | 421 | (defun magithub-handle-errors (status) 422 | "Handle any errors reported in a `url-retrieve' callback. 423 | STATUS is the first argument passed to the callback. 424 | 425 | If there is an error and GitHub returns an error message, that 426 | message is printed with `error'. Otherwise, the HTTP error is 427 | signaled." 428 | (loop for (name val) on status by 'cddr 429 | do (when (eq name :error) 430 | (if (not magithub-handle-errors) 431 | (signal (car val) (cdr val)) 432 | (condition-case err 433 | (let* ((json-object-type 'plist) 434 | (data (json-read)) 435 | (err (plist-get data :error))) 436 | (unless err (signal 'json-readtable-error nil)) 437 | (error "GitHub error: %s" err)) 438 | (json-readtable-error (signal (car val) (cdr val)))))))) 439 | 440 | (defun magithub-retrieve (path callback &optional cbargs) 441 | "Retrieve GitHub API PATH asynchronously. 442 | Call CALLBACK with CBARGS when finished. 443 | 444 | PATH can either be a string or a list of strings. 445 | In the latter case, they're URL-escaped and joined with \"/\". 446 | 447 | Like `url-retrieve', except for the following: 448 | * PATH is an API resource path, not a full URL. 449 | * GitHub authorization is automatically enabled. 450 | * `magithub-request-data' is used instead of `url-request-data'. 451 | * CALLBACK is passed a decoded JSON object (as a plist) rather 452 | than a list of statuses. Basic error handling is done by `magithub-retrieve'. 453 | 454 | If `magithub-parse-response' is nil, CALLBACK is just passed nil 455 | rather than the JSON response object." 456 | (magithub-with-auth 457 | (let ((url-request-data (magithub-make-query-string magithub-request-data))) 458 | (lexical-let ((callback callback) (magithub-parse-response magithub-parse-response)) 459 | (url-retrieve (magit-request-url path) 460 | (lambda (status &rest cbargs) 461 | (when magithub-parse-response 462 | (search-forward "\n\n" nil t)) ; Move past headers 463 | (magithub-handle-errors status) 464 | (apply callback 465 | (if (not magithub-parse-response) 466 | (current-buffer) 467 | (let* ((json-object-type 'plist) 468 | (obj (json-read))) 469 | (kill-buffer) 470 | obj)) 471 | cbargs)) 472 | cbargs))))) 473 | 474 | (defun magithub-retrieve-synchronously (path) 475 | "Retrieve GitHub API PATH synchronously. 476 | 477 | PATH can either be a string or a list of strings. 478 | In the latter case, they're URL-escaped and joined with \"/\". 479 | 480 | Like `url-retrieve-synchronously', except for the following: 481 | * PATH is an API resource path, not a full URL. 482 | * GitHub authorization is automatically enabled. 483 | * `magithub-request-data' is used instead of `url-request-data'. 484 | * Return a decoded JSON object (as a plist) rather than a buffer 485 | containing the response unless `magithub-parse-response' is nil." 486 | (magithub-with-auth 487 | (let ((url-request-data (magithub-make-query-string magithub-request-data))) 488 | (with-current-buffer (url-retrieve-synchronously (magit-request-url path)) 489 | (goto-char (point-min)) 490 | (if (not magithub-parse-response) (current-buffer) 491 | (search-forward "\n\n" nil t) ; Move past headers 492 | (let* ((data (let ((json-object-type 'plist)) (json-read))) 493 | (err (plist-get data :error))) 494 | (when err (error "GitHub error: %s" err)) 495 | (kill-buffer) 496 | data)))))) 497 | 498 | 499 | ;;; Configuration 500 | ;; This API was taken from gist.el (http://github.com/defunkt/gist.el), 501 | ;; and renamed to avoid conflict. The code also uses Magit rather 502 | ;; than relying on the Git executable directly. 503 | 504 | (defun magithub-config (key) 505 | "Returns a GitHub specific value from the global Git config." 506 | (magit-git-string "config" "--global" (concat "github." key))) 507 | 508 | (defun magithub-set-config (key value) 509 | "Sets a GitHub specific value to the global Git config." 510 | (magit-git-string "config" "--global" (concat "github." key) value)) 511 | 512 | (defun magithub-auth-info () 513 | "Returns the user's GitHub authorization information. 514 | Searches for a GitHub username and token in the global git config, 515 | and returns (USERNAME . TOKEN). If nothing is found, prompts 516 | for the info then sets it to the git config." 517 | (interactive) 518 | 519 | (let* ((user (magithub-config "user")) 520 | (token (magithub-config "token"))) 521 | 522 | (when (not user) 523 | (setq user (read-string "GitHub username: ")) 524 | (magithub-set-config "user" user)) 525 | 526 | (when (not token) 527 | (setq token (read-string "GitHub API token: ")) 528 | (magithub-set-config "token" token)) 529 | 530 | (cons user token))) 531 | 532 | 533 | ;;; GitHub Information 534 | 535 | (defun magithub-repos-for-user (user) 536 | "Return an array of all repos owned by USER. 537 | The repos are decoded JSON objects (plists)." 538 | (let ((url-request-method "GET")) 539 | (plist-get 540 | (magithub-retrieve-synchronously 541 | (list "repos" "show" user)) 542 | :repositories))) 543 | 544 | (defun magithub-user-search (user) 545 | "Run a GitHub user search for USER. 546 | Return an array of all matching users. 547 | 548 | WARNING: WARNING: This function currently doesn't work fully, 549 | since GitHub's user search API only returns an apparently random 550 | subset of users." 551 | (if (string= user "") [] 552 | (let ((url-request-method "GET")) 553 | (plist-get 554 | (magithub-retrieve-synchronously 555 | (list "user" "search" string)) 556 | :users)))) 557 | 558 | (defun magithub-repo-obj (&optional username repo) 559 | "Return an object representing the repo USERNAME/REPO. 560 | Defaults to the current repo. 561 | 562 | The returned object is a decoded JSON object (plist)." 563 | (setq username (or username (magithub-repo-owner))) 564 | (setq repo (or repo (magithub-repo-name))) 565 | (remhash (cons username repo) magithub--repo-obj-cache) 566 | (magithub-cached-repo-obj username repo)) 567 | 568 | (defun magithub-cached-repo-obj (&optional username repo) 569 | "Return a (possibly cached) object representing the repo USERNAME/REPO. 570 | Defaults to the current repo. 571 | 572 | The returned object is a decoded JSON object (plist). 573 | 574 | This differs from `magithub-repo-obj' in that it returns a cached 575 | copy of the repo object if one exists. This is useful for 576 | properties such as :parent and :fork that are highly unlikely to 577 | change." 578 | (setq username (or username (magithub-repo-owner))) 579 | (setq repo (or repo (magithub-repo-name))) 580 | (let ((cached (gethash (cons username repo) magithub--repo-obj-cache))) 581 | (or cached 582 | (let* ((url-request-method "GET") 583 | (obj (plist-get 584 | (magithub-retrieve-synchronously 585 | (list "repos" "show" username repo)) 586 | :repository))) 587 | (puthash (cons username repo) obj magithub--repo-obj-cache) 588 | obj)))) 589 | 590 | (defun magithub-repo-collaborators (&optional username repo) 591 | "Return an array of names of collaborators on USERNAME/REPO. 592 | Defaults to the current repo." 593 | (setq username (or username (magithub-repo-owner))) 594 | (setq repo (or repo (magithub-repo-name))) 595 | (let ((url-request-method "GET")) 596 | (plist-get 597 | (magithub-retrieve-synchronously 598 | (list "repos" "show" username repo "collaborators")) 599 | :collaborators))) 600 | 601 | (defun magithub-repo-network (&optional username repo) 602 | "Return an array of forks and/or parents of USERNAME/REPO. 603 | Defaults to the current repo. 604 | 605 | Each fork is a decoded JSON object (plist)." 606 | (setq username (or username (magithub-repo-owner))) 607 | (setq repo (or repo (magithub-repo-name))) 608 | (let ((url-request-method "GET")) 609 | (plist-get 610 | (magithub-retrieve-synchronously 611 | (list "repos" "show" username repo "network")) 612 | :network))) 613 | 614 | (defun magithub-repo-parent-collaborators (&optional username repo) 615 | "Return an array of names of collaborators on the parent of USERNAME/REPO. 616 | These are the default recipients of a pull request for this repo. 617 | Defaults to the current repo. 618 | 619 | If this repo has no parents, return the collaborators for it instead." 620 | (let ((parent (plist-get (magithub-cached-repo-obj username repo) :parent))) 621 | (if (not parent) (magithub-repo-collaborators username repo) 622 | (destructuring-bind (parent-owner . parent-repo) (magithub-parse-repo parent) 623 | (magithub-repo-collaborators parent-owner parent-repo))))) 624 | 625 | (defun magithub-untracked-forks () 626 | "Return a list of forks of this repo that aren't being tracked as remotes. 627 | Returned repos are decoded JSON objects (plists)." 628 | (lexical-let ((remotes (magit-git-lines "remote"))) 629 | (delq "origin" remotes) 630 | (push (magithub-repo-owner) remotes) 631 | (magithub--remove-if 632 | (lambda (repo) (member-ignore-case (plist-get repo :owner) remotes)) 633 | (magithub-repo-network)))) 634 | 635 | 636 | ;;; Local Repo Information 637 | 638 | (defun magithub-repo-info () 639 | "Return information about this GitHub repo. 640 | This is of the form given by `magithub-remote-info'. 641 | 642 | Error out if this isn't a GitHub repo." 643 | (or (magithub-remote-info "origin") 644 | (error "Not in a GitHub repo"))) 645 | 646 | (defun magithub-repo-owner () 647 | "Return the name of the owner of this GitHub repo. 648 | 649 | Error out if this isn't a GitHub repo." 650 | (car (magithub-repo-info))) 651 | 652 | (defun magithub-repo-name () 653 | "Return the name of this GitHub repo. 654 | 655 | Error out if this isn't a GitHub repo." 656 | (cadr (magithub-repo-info))) 657 | 658 | (defun magithub-repo-ssh-p () 659 | "Return non-nil if this GitHub repo is checked out via SSH. 660 | 661 | Error out if this isn't a GitHub repo." 662 | (caddr (magithub-repo-info))) 663 | 664 | 665 | ;;; Diff Information 666 | 667 | (defun magithub-section-index (section) 668 | "Return the index of SECTION as a child of its parent section." 669 | (magithub--position section (magit-section-children (magit-section-parent section)))) 670 | 671 | (defun magithub-hunk-lines () 672 | "Return the two line numbers for the current line (which should be in a hunk). 673 | The first number is the line number in the original file, the 674 | second is the line number in the new file. They're returned 675 | as (L1 L2). If either doesn't exist, it will be nil. 676 | 677 | If something goes wrong (e.g. we're not in a hunk or it's in an 678 | unknown format), return nil." 679 | (block nil 680 | (let ((point (point))) 681 | (save-excursion 682 | (beginning-of-line) 683 | (when (looking-at "@@") ;; Annotations don't have line numbers, 684 | (forward-line) ;; so we'll approximate with the next line. 685 | (setq point (point))) 686 | (goto-char (magit-section-beginning (magit-current-section))) 687 | (unless (looking-at "@@ -\\([0-9]+\\)\\(?:,[0-9]+\\)? \\+\\([0-9]+\\)") (return)) 688 | (let ((l (- (string-to-number (match-string 1)) 1)) 689 | (r (- (string-to-number (match-string 2)) 1))) 690 | (forward-line) 691 | (while (<= (point) point) 692 | (unless (looking-at "\\+") (incf l)) 693 | (unless (looking-at "-") (incf r)) 694 | (forward-line)) 695 | (forward-line -1) 696 | (list (unless (looking-at "\\+") l) (unless (looking-at "-") r))))))) 697 | 698 | 699 | ;;; Network 700 | 701 | (defun magithub-track (username &optional repo fetch) 702 | "Track USERNAME/REPO as a remote. 703 | If FETCH is non-nil, fetch that remote. 704 | 705 | Interactively, prompts for the username and repo. With a prefix 706 | arg, fetches the remote." 707 | (interactive 708 | (destructuring-bind (username . repo) (magithub-read-untracked-fork) 709 | (list username repo current-prefix-arg))) 710 | (magit-run-git "remote" "add" username (magithub-repo-url username repo)) 711 | (when fetch (magit-run-git-async "remote" "update" username)) 712 | (message "Tracking %s/%s%s" username repo 713 | (if fetch ", fetching..." ""))) 714 | 715 | 716 | ;;; Browsing 717 | 718 | (defun magithub-browse (&rest path-and-anchor) 719 | "Load http://github.com/PATH#ANCHOR in a web browser and add it to the kill ring. 720 | Any nil elements of PATH are ignored. 721 | 722 | \n(fn &rest PATH [:anchor ANCHOR])" 723 | (destructuring-bind (path anchor) 724 | (loop for el on path-and-anchor 725 | if (car el) 726 | unless (eq (car el) :anchor) collect (car el) into path 727 | else return (list path (cadr el)) 728 | finally return (list path nil)) 729 | (let ((url (concat "http://github.com/" (mapconcat 'identity path "/")))) 730 | (when anchor (setq url (concat url "#" anchor))) 731 | (kill-new url) 732 | (browse-url url)))) 733 | 734 | (defun magithub-browse-current (&rest path-and-anchor) 735 | "Load http://github.com/USER/REPO/PATH#ANCHOR in a web browser. 736 | With ANCHOR, loads the URL with that anchor. 737 | 738 | USER is `magithub-repo-owner' and REPO is `magithub-repo-name'. 739 | 740 | \n(fn &rest PATH [:anchor ANCHOR])" 741 | (apply 'magithub-browse (magithub-repo-owner) (magithub-repo-name) path-and-anchor)) 742 | 743 | (defun magithub-browse-repo () 744 | "Show the GitHub webpage for the current branch of this repository." 745 | ;; Don't use name-rev-for-remote here because we want it to work 746 | ;; even if the branches are out-of-sync. 747 | (magithub-browse-current "tree" (magit-name-rev "HEAD"))) 748 | 749 | (defun magithub-browse-commit (commit &optional anchor) 750 | "Show the GitHub webpage for COMMIT. 751 | COMMIT should be the SHA of a commit. 752 | 753 | If ANCHOR is given, it's used as the anchor in the URL." 754 | (let ((info (magithub-remote-info-for-commit commit))) 755 | (if info (magithub-browse (car info) (cadr info) "commit" commit :anchor anchor) 756 | (error "Commit %s hasn't been pushed" (substring commit 0 8))))) 757 | 758 | (defun magithub-browse-commit-diff (diff-section) 759 | "Show the GitHub webpage for the diff displayed in DIFF-SECTION. 760 | This must be a diff for `magit-currently-shown-commit'." 761 | (magithub-browse-commit 762 | magit-currently-shown-commit 763 | (format "diff-%d" (magithub-section-index diff-section)))) 764 | 765 | (defun magithub-browse-commit-hunk-at-point () 766 | "Show the GitHub webpage for the hunk at point. 767 | This must be a hunk for `magit-currently-shown-commit'." 768 | (destructuring-bind (l r) (magithub-hunk-lines) 769 | (magithub-browse-commit 770 | magit-currently-shown-commit 771 | (format "L%d%s" (magithub-section-index (magit-section-parent 772 | (magit-current-section))) 773 | (if l (format "L%d" l) (format "R%d" r)))))) 774 | 775 | (defun magithub-name-ref-for-compare (ref remote) 776 | "Return a human-readable name for REF that's valid in the compare view for REMOTE. 777 | This is like `magithub-name-rev-for-remote', but takes into 778 | account comparing across repos. 779 | 780 | To avoid making an HTTP request, this method assumes that if REV 781 | is in a remote, that repo is a GitHub fork." 782 | (let ((remotes (magithub-remotes-containing-ref ref))) 783 | ;; If remotes is empty, we let magithub-name-rev-for-remote's 784 | ;; error-handling deal with it. 785 | (if (or (member remote remotes) (null remotes)) 786 | (magithub-name-rev-for-remote ref remote) 787 | (let ((remote-for-ref (car remotes))) 788 | (concat remote-for-ref ":" 789 | (magithub-name-rev-for-remote ref remote-for-ref)))))) 790 | 791 | (defun magithub-browse-compare (from to &optional anchor) 792 | "Show the GitHub webpage comparing refs FROM and TO. 793 | 794 | If ANCHOR is given, it's used as the anchor in the URL." 795 | (magithub-browse-current 796 | "compare" (format "%s...%s" 797 | (magithub-name-ref-for-compare from "origin") 798 | (magithub-name-ref-for-compare to "origin")) 799 | :anchor anchor)) 800 | 801 | (defun magithub-browse-diffbuff (&optional anchor) 802 | "Show the GitHub webpage comparing refs corresponding to the current diff buffer. 803 | 804 | If ANCHOR is given, it's used as the anchor in the URL." 805 | (when (and (listp magit-current-range) (null (cdr magit-current-range))) 806 | (setq magit-current-range (car magit-current-range))) 807 | (if (stringp magit-current-range) 808 | (progn 809 | (unless (magit-everything-clean-p) 810 | (error "Diff includes dirty working directory")) 811 | (magithub-browse-compare magit-current-range 812 | (magithub-name-rev-for-remote "HEAD" "origin") 813 | anchor)) 814 | (magithub-browse-compare (car magit-current-range) (cdr magit-current-range) anchor))) 815 | 816 | (defun magithub-browse-diff (section) 817 | "Show the GitHub webpage for the diff displayed in DIFF-SECTION. 818 | This must be a diff from a *magit-diff* buffer." 819 | (magithub-browse-diffbuff (format "diff-%d" (magithub-section-index diff-section)))) 820 | 821 | (defun magithub-browse-hunk-at-point () 822 | "Show the GitHub webpage for the hunk at point. 823 | This must be a hunk from a *magit-diff* buffer." 824 | (destructuring-bind (l r) (magithub-hunk-lines) 825 | (magithub-browse-diffbuff 826 | (format "L%d%s" (magithub-section-index (magit-section-parent 827 | (magit-current-section))) 828 | (if l (format "L%d" l) (format "R%d" r)))))) 829 | 830 | (defun magithub-browse-blob (path &optional anchor) 831 | "Show the GitHub webpage for the blob at PATH. 832 | 833 | If ANCHOR is given, it's used as the anchor in the URL." 834 | (magithub-browse-current "blob" (magithub-name-rev-for-remote "HEAD" "origin") 835 | path :anchor anchor)) 836 | 837 | (defun magithub-browse-item () 838 | "Load a GitHub webpage describing the item at point. 839 | The URL of the webpage is added to the kill ring." 840 | (interactive) 841 | (or 842 | (magit-section-action (item info "browse") 843 | ((commit) (magithub-browse-commit info)) 844 | ((diff) 845 | (case magit-submode 846 | (commit (magithub-browse-commit-diff (magit-current-section))) 847 | (diff (magithub-browse-diff (magit-current-section))))) 848 | ((hunk) 849 | (case magit-submode 850 | (commit (magithub-browse-commit-hunk-at-point)) 851 | (diff (magithub-browse-hunk-at-point)))) 852 | (t 853 | (case magit-submode 854 | (commit (magithub-browse-commit magit-currently-shown-commit)) 855 | (diff (magithub-browse-diffbuff))))) 856 | (magithub-browse-repo))) 857 | 858 | (defun magithub-browse-file () 859 | "Show the GitHub webpage for the current file. 860 | The URL for the webpage is added to the kill ring. This only 861 | works within `magithub-minor-mode'. 862 | 863 | In Transient Mark mode, if the mark is active, highlight the 864 | contents of the region." 865 | (interactive) 866 | (let ((path (magithub-repo-relative-path)) 867 | (start (line-number-at-pos (region-beginning))) 868 | (end (line-number-at-pos (region-end)))) 869 | (when (eq (char-before (region-end)) ?\n) (decf end)) 870 | (with-current-buffer magithub-status-buffer 871 | (magithub-browse-blob 872 | path (when (and transient-mark-mode mark-active) 873 | (if (eq start end) (format "L%d" start) 874 | (format "L%d-%d" start end))))))) 875 | 876 | 877 | ;;; Creating Repos 878 | 879 | (defun magithub-gist-repo (&optional private) 880 | "Upload the current repo as a Gist. 881 | If PRIVATE is non-nil or with a prefix arg, the Gist is private. 882 | 883 | Copies the URL of the Gist into the kill ring. If 884 | `magithub-view-gist' is non-nil (the default), opens the gist in 885 | the browser with `browse-url'." 886 | (interactive "P") 887 | (let ((url-max-redirections 0) 888 | (url-request-method "POST") 889 | (magithub-api-base magithub-gist-url) 890 | (magithub-request-data 891 | `(,@(if private '(("private" . "1"))) 892 | ("file_ext[gistfile1]" . ".dummy") 893 | ("file_name[gistfile1]" . "dummy") 894 | ("file_contents[gistfile1]" . 895 | "Dummy Gist created by Magithub. To be replaced with a real repo."))) 896 | magithub-parse-response) 897 | (let (url) 898 | (with-current-buffer (magithub-retrieve-synchronously "gists") 899 | (goto-char (point-min)) 900 | (re-search-forward "^Location: \\(.*\\)$") 901 | (setq url (match-string 1)) 902 | (kill-buffer)) 903 | (kill-new url) 904 | (let ((ssh-url (replace-regexp-in-string 905 | "^http://gist\\.github\\.com/" 906 | "git@gist.github.com:" url))) 907 | (magit-run-git "remote" "add" "origin" ssh-url) 908 | (magit-set "origin" "branch" "master" "remote") 909 | (magit-set "refs/heads/master" "branch" "master" "merge") 910 | (magit-run-git-async "push" "-v" "-f" "origin" "master") 911 | (when magithub-view-gist (browse-url url)) 912 | (message "Gist created: %s" url))))) 913 | 914 | (defun magithub-create-from-local (name &optional description homepage private) 915 | "Create a new GitHub repository for the current Git repository. 916 | NAME is the name of the GitHub repository, DESCRIPTION describes 917 | the repository, URL is the location of the homepage. If PRIVATE 918 | is non-nil, a private repo is created. 919 | 920 | When called interactively, prompts for NAME, DESCRIPTION, and 921 | HOMEPAGE. NAME defaults to the name of the current Git 922 | directory. By default, creates a public repo; with a prefix arg, 923 | creates a private repo." 924 | (interactive 925 | (list (read-string "Repository name: " 926 | (file-name-nondirectory 927 | (directory-file-name 928 | (expand-file-name 929 | (magit-get-top-dir default-directory))))) 930 | (read-string "Description: ") 931 | (read-string "Homepage: ") 932 | current-prefix-arg)) 933 | 934 | (let ((url-request-method "POST") 935 | (magithub-request-data `(("name" . ,name) 936 | ("description" . ,description) 937 | ("homepage" . ,homepage) 938 | ("private" . ,(if private "0" "1"))))) 939 | (magithub-retrieve "repos/create" 940 | (lambda (data name) 941 | (magit-git-string 942 | "remote" "add" "origin" 943 | (magithub-repo-url (magithub-config "user") name 'ssh)) 944 | (magit-set "origin" "branch" "master" "remote") 945 | (magit-set "refs/heads/master" "branch" "master" "merge") 946 | (magit-run-git-async "push" "-v" "origin" "master") 947 | (message "GitHub repository created: %s" 948 | (plist-get (plist-get data :repository) :url))) 949 | (list name)))) 950 | 951 | ;;;###autoload 952 | (defun magithub-clone (username repo dir &optional sshp) 953 | "Clone GitHub repo USERNAME/REPO into directory DIR. 954 | If SSHP is non-nil, clone it using the SSH URL. Once the repo is 955 | cloned, switch to a `magit-status' buffer for it. 956 | 957 | Interactively, prompts for the repo name and directory. With a 958 | prefix arg, clone using SSH." 959 | (interactive 960 | (destructuring-bind (username . repo) (magithub-read-repo "Clone repo (user/repo): ") 961 | (list username repo (read-directory-name "Parent directory: ") current-prefix-arg))) 962 | ;; The trailing slash is necessary for Magit to be able to figure out 963 | ;; that this is actually a directory, not a file 964 | (let ((dir (concat (directory-file-name (expand-file-name dir)) "/" repo "/"))) 965 | (magit-run-git "clone" (magithub-repo-url username repo sshp) dir) 966 | (magit-status dir))) 967 | 968 | 969 | ;;; Message Mode 970 | 971 | (defconst magithub-message-buffer-name "*magithub-edit-message*" 972 | "Buffer name for composing messages.") 973 | 974 | (defconst magithub-message-header-end "-- End of Magithub header --\n") 975 | 976 | (defvar magithub-message-mode-map 977 | (let ((map (make-sparse-keymap))) 978 | (define-key map (kbd "C-c C-c") 'magithub-message-send) 979 | (define-key map (kbd "C-c C-k") 'magithub-message-cancel) 980 | (define-key map (kbd "C-c C-]") 'magithub-message-cancel) 981 | map) 982 | "The keymap for `magithub-message-mode'.") 983 | 984 | (defvar magithub-pre-message-window-configuration nil) 985 | 986 | (macrolet 987 | ((define-it (parent-mode) 988 | `(define-derived-mode magithub-message-mode ,parent-mode "Magithub Message Edit" 989 | "A mode for editing pull requests and other GitHub messages." 990 | (run-mode-hooks 'magithub-message-mode-hook)))) 991 | (if (featurep 'markdown-mode) (define-it markdown-mode) 992 | (define-it text-mode))) 993 | 994 | (defmacro with-magithub-message-mode (&rest body) 995 | "Runs BODY with Magit's log-edit functions usable with Magithub's message mode." 996 | (declare (indent 0)) 997 | `(let ((magit-log-edit-buffer-name magithub-message-buffer-name) 998 | (magit-log-header-end magithub-message-header-end) 999 | (magit-log-edit-confirm-cancellation 1000 | magithub-message-confirm-cancellation) 1001 | (magit-pre-log-edit-window-configuration 1002 | magithub-pre-message-window-configuration)) 1003 | (unwind-protect (progn ,@body) 1004 | (setq magithub-pre-message-window-configuration 1005 | magit-pre-log-edit-window-configuration)))) 1006 | 1007 | (defun magithub-pop-to-message (operation) 1008 | "Open up a `magithub-message-mode' buffer and switch to it. 1009 | OPERATION is the name of what will happen when C-c C-c is used, 1010 | printed as a message when the buffer is opened." 1011 | (let ((dir default-directory) 1012 | (buf (get-buffer-create magithub-message-buffer-name))) 1013 | (setq magithub-pre-message-window-configuration 1014 | (current-window-configuration)) 1015 | (pop-to-buffer buf) 1016 | (setq default-directory dir) 1017 | (magithub-message-mode) 1018 | (message "Type C-c C-c to %s (C-c C-k to cancel)." operation))) 1019 | 1020 | (defun magithub-message-send () 1021 | "Finish writing the message and send it." 1022 | (interactive) 1023 | (let ((recipients (with-magithub-message-mode 1024 | (magit-log-edit-get-field 'recipients)))) 1025 | (with-magithub-message-mode (magit-log-edit-set-fields nil)) 1026 | (magithub-send-pull-request 1027 | (buffer-string) (split-string recipients crm-separator)) 1028 | (let (magithub-message-confirm-cancellation) 1029 | (magithub-message-cancel)))) 1030 | 1031 | (defun magithub-message-cancel () 1032 | "Abort and erase message being composed." 1033 | (interactive) 1034 | (with-magithub-message-mode (magit-log-edit-cancel-log-message))) 1035 | 1036 | 1037 | ;;; Forking Repos 1038 | 1039 | (defun magithub-fork-current () 1040 | "Fork the current repository in place." 1041 | (interactive) 1042 | (destructuring-bind (owner repo _) (magithub-repo-info) 1043 | (let ((url-request-method "POST")) 1044 | (magithub-retrieve (list "repos" "fork" owner repo) 1045 | (lambda (obj repo buffer) 1046 | (with-current-buffer buffer 1047 | (magit-with-refresh 1048 | (magit-set (magithub-repo-url 1049 | (car (magithub-auth-info)) 1050 | repo 'ssh) 1051 | "remote" "origin" "url"))) 1052 | (message "Forked %s/%s" owner repo)) 1053 | (list repo (current-buffer)))))) 1054 | 1055 | (defun magithub-send-pull-request (text recipients) 1056 | "Send a pull request with text TEXT to RECIPIENTS. 1057 | RECIPIENTS should be a list of usernames." 1058 | (let ((url-request-method "POST") 1059 | (magithub-request-data (cons (cons "message[body]" text) 1060 | (mapcar (lambda (recipient) 1061 | (cons "message[to][]" recipient)) 1062 | recipients))) 1063 | (magithub-api-base magithub-github-url) 1064 | (url-max-redirections 0) ;; GitHub will try to redirect, but we don't care 1065 | magithub-parse-response) 1066 | (magithub-retrieve (list (magithub-repo-owner) (magithub-repo-name) 1067 | "pull_request" (magithub-name-rev-for-remote "HEAD" "origin")) 1068 | (lambda (_) 1069 | (kill-buffer) 1070 | (message "Your pull request was sent."))))) 1071 | 1072 | (defun magithub-pull-request (recipients) 1073 | "Compose a pull request and send it to RECIPIENTS. 1074 | RECIPIENTS should be a list of usernames. 1075 | 1076 | Interactively, reads RECIPIENTS via `magithub-read-pull-request-recipients'. 1077 | For non-interactive pull requests, see `magithub-send-pull-request'." 1078 | (interactive (list (magithub-read-pull-request-recipients))) 1079 | (with-magithub-message-mode 1080 | (magit-log-edit-set-field 1081 | 'recipients (mapconcat 'identity recipients crm-separator))) 1082 | (magithub-pop-to-message "send pull request")) 1083 | 1084 | (defun magithub-toggle-ssh (&optional arg) 1085 | "Toggle whether the current repo is checked out via SSH. 1086 | With ARG, use SSH if and only if ARG is positive." 1087 | (interactive "P") 1088 | (if (null arg) (setq arg (if (magithub-repo-ssh-p) -1 1)) 1089 | (setq arg (prefix-numeric-value arg))) 1090 | (magit-set (magithub-repo-url (magithub-repo-owner) (magithub-repo-name) (> arg 0)) 1091 | "remote" "origin" "url") 1092 | (magit-refresh-status)) 1093 | 1094 | 1095 | ;;; Minor Mode 1096 | 1097 | (defvar magithub-minor-mode-map 1098 | (let ((map (make-sparse-keymap))) 1099 | (define-key map (kbd "C-c ' b") 'magithub-browse-file) 1100 | map)) 1101 | 1102 | (defvar magithub-status-buffer nil 1103 | "The Magit status buffer for the current buffer's Git repository.") 1104 | (make-variable-buffer-local 'magithub-status-buffer) 1105 | 1106 | (define-minor-mode magithub-minor-mode 1107 | "Minor mode for files in a GitHub repository. 1108 | 1109 | \\{magithub-minor-mode-map}" 1110 | :keymap magithub-minor-mode-map) 1111 | 1112 | (defun magithub-try-enabling-minor-mode () 1113 | "Activate `magithub-minor-mode' in this buffer if it's a Git buffer. 1114 | This means it's visiting a Git-controlled file and a Magit buffer 1115 | is open for that file's repo." 1116 | (block nil 1117 | (if magithub-minor-mode (return)) 1118 | (unless buffer-file-name (return)) 1119 | ;; Try to find the Magit status buffer for this file. 1120 | ;; If it doesn't exist, don't activate magithub-minor-mode. 1121 | (let* ((topdir (magit-get-top-dir (file-name-directory buffer-file-name))) 1122 | (status (magit-find-buffer 'status topdir))) 1123 | (unless status (return)) 1124 | (magithub-minor-mode 1) 1125 | (setq magithub-status-buffer status)))) 1126 | 1127 | (defun magithub-try-disabling-minor-mode () 1128 | "Deactivate `magithub-minor-mode' in this buffer if it's no longer a Git buffer. 1129 | See `magithub-try-enabling-minor-mode'." 1130 | (when (and magithub-minor-mode (buffer-live-p magithub-status-buffer)) 1131 | (magithub-minor-mode -1))) 1132 | 1133 | (defun magithub-try-enabling-minor-mode-for-repo () 1134 | "Run `magithub-try-enabling-minor-mode' on all buffers in the current repo." 1135 | (let* ((repo-directory (magit-get-top-dir default-directory)) 1136 | regexp) 1137 | (when repo-directory 1138 | (setq regexp (concat "^" (regexp-quote repo-directory))) 1139 | (dolist (buf (buffer-list)) 1140 | (with-current-buffer buf 1141 | (when (and (buffer-file-name) 1142 | (string-match-p regexp default-directory)) 1143 | (magithub-try-enabling-minor-mode))))))) 1144 | 1145 | (defun magithub-try-disabling-minor-mode-everywhere () 1146 | "Run `magithub-try-disabling-minor-mode' on all buffers." 1147 | (dolist (buf (buffer-list)) 1148 | (with-current-buffer buf (magithub-try-disabling-minor-mode)))) 1149 | 1150 | ;;; Hooks into Magit and Emacs 1151 | 1152 | (defun magithub-magit-init-hook () 1153 | (when (y-or-n-p "Create GitHub repo? ") 1154 | (call-interactively 'magithub-create-from-local))) 1155 | (add-hook 'magit-init-hook 'magithub-magit-init-hook) 1156 | 1157 | (defun magithub-magit-mode-hook () 1158 | "Enable `magithub-minor-mode' in buffers that are now in a Magit repo. 1159 | If the new `magit-mode' buffer is a status buffer, try enabling 1160 | `magithub-minor-mode' in all buffers for that repo." 1161 | (when (derived-mode-p 'magit-status-mode) 1162 | (magithub-try-enabling-minor-mode-for-repo))) 1163 | (add-hook 'magit-mode-hook 'magithub-magit-mode-hook) 1164 | 1165 | (defun magithub-kill-buffer-hook () 1166 | "Clean up `magithub-minor-mode'. 1167 | That is, if the buffer being killed is a Magit status buffer, 1168 | deactivate `magithub-minor-mode' on all buffers in its repository." 1169 | (when (and (eq major-mode 'magit-mode) (derived-mode-p 'magit-status-mode)) 1170 | (magithub-try-disabling-minor-mode-everywhere))) 1171 | (add-hook 'kill-buffer-hook 'magithub-kill-buffer-hook) 1172 | 1173 | (add-hook 'find-file-hook 'magithub-try-enabling-minor-mode) 1174 | 1175 | (defun magithub-try-enabling-minor-mode-everywhere () 1176 | (dolist (buf (buffer-list)) 1177 | (with-current-buffer buf 1178 | (magithub-magit-mode-hook)))) 1179 | 1180 | (magithub-try-enabling-minor-mode-everywhere) 1181 | 1182 | (provide 'magithub) 1183 | 1184 | ;;;###autoload 1185 | (eval-after-load 'magit 1186 | '(unless (featurep 'magithub) 1187 | (require 'magithub))) 1188 | 1189 | ;;; magithub.el ends here 1190 | -------------------------------------------------------------------------------- /magithub.texi: -------------------------------------------------------------------------------- 1 | \input texinfo.tex @c -*-texinfo-*- 2 | @c %**start of header 3 | @setfilename magithub.info 4 | @settitle Magithub User Manual 5 | @documentencoding utf-8 6 | @c %**end of header 7 | 8 | @dircategory Emacs 9 | @direntry 10 | * Magithub: (magithub). Using GitHub from Emacs with Magit. 11 | @end direntry 12 | 13 | @copying 14 | Copyright @copyright{} 2010 Nathan Weizenbaum 15 | 16 | @quotation 17 | Permission is granted to copy, distribute and/or modify this document 18 | under the terms of the GNU Free Documentation License, Version 1.2 or 19 | any later version published by the Free Software Foundation; with no 20 | Invariant Sections, with no Front-Cover Texts, and with no Back-Cover 21 | Texts. 22 | @end quotation 23 | @end copying 24 | 25 | @node Top 26 | @top Magithub User Manual 27 | 28 | Magithub is an Emacs interface for GitHub 29 | built on top of the Magit interface to Git. 30 | 31 | @menu 32 | * Introduction:: 33 | * New Repositories:: 34 | * Existing Repositories:: 35 | * GitHub Network:: 36 | * GitHub Website:: 37 | @end menu 38 | 39 | @node Introduction 40 | @chapter Introduction 41 | 42 | Magithub extends Magit by adding functionality specific to GitHub. 43 | It adds commands for creating and forking GitHub repositories, 44 | tracking forks on GitHub, sending pull requests, 45 | and using the @url{http://gist.github.com, Gist} paste site. 46 | It also provides a strong basis for further Emacs interaction with GitHub. 47 | 48 | This manual covers all the user-facing features of Magithub, 49 | which are mostly in the form of additional commands 50 | added to the @code{magit-status} buffer. 51 | It assumes familiarity with Git and GitHub, 52 | and at least some familiarity with Magit. 53 | 54 | All Magithub commands accessible in the status buffer 55 | begin with the prefix @kbd{'} (single quote) 56 | to avoid conflicting with built-in Magit commands. 57 | Just as in Magit, these commands will be available in all @code{magit-mode} buffers, 58 | but may not necessarily make sense in all contexts. 59 | 60 | @node New Repositories 61 | @chapter New Repositories 62 | 63 | @section GitHub Repositories 64 | 65 | To push the current Git repository to a new GitHub repository, type @kbd{' C}. 66 | This will prompt for various bits of information (name, description, homepage), 67 | create the GitHub repository, and push. 68 | Once the GitHub repository has been created, 69 | Magithub will make it the default remote repository. 70 | 71 | By default, the new GitHub repository is public. 72 | With a prefix arg, it will be private instead. 73 | 74 | @section Gists 75 | 76 | You can also push the current Git repository 77 | as a @url{http://gist.github.com, Gist} by typing @kbd{' g}, 78 | since Gists are just Git repositories. 79 | 80 | When a Gist is created, the URL is copied to the kill ring 81 | and it's opened in the browser. 82 | You can disable the latter by setting @code{magithub-view-gist} to @code{nil}. 83 | 84 | Magithub does not support pasting snippets of files to Gist. 85 | For that, @url{http://github.com/defunkt/gist.el, gist.el} is more appropriate. 86 | 87 | @node Existing Repositories 88 | @chapter Existing Repositories 89 | 90 | @section Cloning 91 | 92 | You can clone a GitHub repository by typing @kbd{' c}. 93 | This will prompt for the repository to clone (of the form @code{USERNAME/REPONAME}) 94 | and the location of the repository, 95 | then clone it and bring up the new Magit status buffer. 96 | With a prefix argument, it will clone from the private URL 97 | (e.g. @code{git@@github.com:nex3/magit.git} 98 | rather than @code{http://github.com/nex3/magit.git}). 99 | 100 | You can also clone a GitHub repository outside of the status buffer 101 | by typing @kbd{M-x magithub-clone}. 102 | By default, this isn't bound to a key, 103 | but if you make a lot of clones you might want to bind it. 104 | 105 | You can toggle between the private URL and the public URL using @kbd{' S}. 106 | 107 | @section Forking 108 | 109 | Once you've checked out a GitHub repository, you can fork it by typing @kbd{' f}. 110 | This will create a fork on GitHub and set that fork up as the default remote repository. 111 | 112 | @node GitHub Network 113 | @chapter GitHub Network 114 | 115 | @section Tracking 116 | 117 | You can track a user's fork of your repository by typing @kbd{' t} 118 | and providing the user's name, and the name of the repository 119 | if it's different than your repository's name. 120 | This adds a remote named after the user tracking their fork. 121 | With a prefix argument, it fetches the remote as well. 122 | 123 | @node GitHub Website 124 | @chapter GitHub Website 125 | 126 | Most Magit and Magithub buffers have direct parallels on the GitHub website. 127 | Magithub makes these all available by typing @kbd{' b} 128 | (or @kbd{C-c ' b} in non-Magit buffers; @pxref{Other Browsable Buffers}). 129 | This opens the relevant GitHub page in your browser using @code{browse-url} 130 | and copies the URL to the kill ring. 131 | 132 | @section Browsable Magit Buffers 133 | 134 | Typing @kbd{' b} when the point is on a commit 135 | in a log or status buffer will show that commit on GitHub. 136 | Otherwise, browsing the Magit status buffer 137 | will show the main page for the GitHub repo 138 | on the current HEAD. 139 | 140 | Typing @kbd{' b} in a commit buffer will also show that commit on GitHub. 141 | If the point is on a particular section of the commit diff, 142 | the browser will open to that section. 143 | 144 | Typing @kbd{' b} in a diff buffer will show the corresponding compare page on GitHub. 145 | Like commits, if the point is on a particular section of the diff, 146 | the browser will open to that section. 147 | 148 | @node Other Browsable Buffers 149 | @section Other Browsable Buffers 150 | 151 | Magithub includes a minor mode that allows it to provide commands 152 | in buffers for files that are checked in to git. 153 | Like in Magit buffers, all Magithub commands are under a single namespace. 154 | In normal files, this is @kbd{C-c '}. 155 | 156 | Currently the only command in Magithub minor mode is @code{magithub-browse-file}, 157 | which can be run by typing @kbd{C-c ' b}. 158 | This opens the current file in the browser, 159 | to the place where the point is. 160 | If @code{transient-mark-mode} is active, 161 | the highlighted region will also be highlighted on the web page. 162 | 163 | @bye 164 | --------------------------------------------------------------------------------