Personal emacs config
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

320 lines
12 KiB

  1. ;;; dired-rsync.el --- Allow rsync from dired buffers -*- lexical-binding: t -*-
  2. ;;
  3. ;; Copyright (C) 2018 Alex Bennée
  4. ;;
  5. ;; Author: Alex Bennée <alex@bennee.com>
  6. ;; Maintainer: Alex Bennée <alex@bennee.com>
  7. ;; Version: 0.5
  8. ;; Package-Requires: ((s "1.12.0") (dash "2.0.0") (emacs "24"))
  9. ;; Homepage: https://github.com/stsquad/dired-rsync
  10. ;;
  11. ;; This file is not part of GNU Emacs.
  12. ;;
  13. ;; This file is free software; you can redistribute it and/or modify
  14. ;; it under the terms of the GNU General Public License as published by
  15. ;; the Free Software Foundation; either version 3, or (at your option)
  16. ;; any later version.
  17. ;;
  18. ;; This file is distributed in the hope that it will be useful,
  19. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  21. ;; GNU General Public License for more details.
  22. ;;
  23. ;; You should have received a copy of the GNU General Public License
  24. ;; along with this program. If not, see <http://www.gnu.org/licenses/>.
  25. ;;
  26. ;;; Commentary:
  27. ;;
  28. ;; dired-rsync is a command that can be run from a dired buffer to
  29. ;; copy files using rsync rather than tramps in-built mechanism.
  30. ;; This is especially useful for copying large files to/from remote
  31. ;; locations without locking up tramp.
  32. ;;
  33. ;; To use simply open a dired buffer, mark some files and invoke
  34. ;; dired-rsync. After being prompted for a location to copy to an
  35. ;; inferior rsync process will be spawned.
  36. ;;
  37. ;; Wherever the files are selected from the rsync will always run from
  38. ;; your local machine.
  39. ;;
  40. (require 'tramp) ; for tramp-tramp-file-p
  41. (require 'dired-aux) ; for dired-dwim-target-directory
  42. (require 'dash)
  43. (require 's)
  44. (require 'rx)
  45. ;;; Code:
  46. ;; Customisation options
  47. (defcustom dired-rsync-command "rsync"
  48. "The rsync binary that we are going to use."
  49. :type 'string
  50. :group 'dired-rsync)
  51. (defcustom dired-rsync-options "-az --info=progress2"
  52. "The default options for the rsync command."
  53. :type 'string
  54. :group 'dired-rsync)
  55. (defcustom dired-rsync-unmark-on-completion t
  56. "Control if dired-rsync should unmark when complete."
  57. :type 'boolean
  58. :group 'dired-rsync)
  59. (defun dired-rsync--default-fetch-marked-files ()
  60. "Default fetcher of marked files."
  61. (dired-get-marked-files nil current-prefix-arg))
  62. (defcustom dired-rsync-source-files 'dired-rsync--default-fetch-marked-files
  63. "Function to collect the list of source files from dired."
  64. :type 'function
  65. :group 'dired-sync)
  66. (defun dired-rsync--default-rsync-failed ()
  67. "Report rsync failure to console."
  68. (message "dired-rsync: failed (see %s for details)"
  69. (current-buffer)))
  70. (defun dired-rsync--pop-to-rsync-failed-buf ()
  71. "Jump to a recently failed buffer."
  72. (pop-to-buffer-same-window (current-buffer)))
  73. (defcustom dired-rsync-failed-hook '(dired-rsync--default-rsync-failed)
  74. "Hook run when rsync fails.
  75. It is run in the context of the failed process buffer."
  76. :type 'hook
  77. :group 'dired-rsync)
  78. ;; Internal variables
  79. (defvar dired-rsync-job-count 0
  80. "Count of running rsync jobs.")
  81. (defvar dired-rsync-modeline-status
  82. ""
  83. "A string defining current `dired-rsync' status, useful for modelines.")
  84. (defvar dired-rsync-passphrase-stall-regex
  85. (rx "Enter passphrase for key")
  86. "A regex to detect passphrase prompts.")
  87. (defvar dired-rsync-percent-complete-regex
  88. (rx (** 1 3 digit) "%")
  89. "A regex to extract the % complete from a file.")
  90. ;; Helpers
  91. (defun dired-rsync--quote-and-maybe-convert-from-tramp (file-or-path)
  92. "Reformat a tramp FILE-OR-PATH to one usable for rsync."
  93. (if (tramp-tramp-file-p file-or-path)
  94. ;; tramp format is /method:remote:path
  95. (let ((parts (s-split ":" file-or-path)))
  96. (format "%s:\"%s\"" (nth 1 parts) (shell-quote-argument (nth 2 parts))))
  97. (shell-quote-argument file-or-path)))
  98. (defun dired-rsync--extract-host-from-tramp (file-or-path &optional split-user)
  99. "Extract the tramp host part of FILE-OR-PATH.
  100. It SPLIT-USER is set we remove the user@ part as well. We assume
  101. hosts don't need quoting."
  102. (let ((parts (s-split ":" file-or-path)))
  103. (let ((host (nth 1 parts)))
  104. (if (and split-user (s-contains? "@" host))
  105. (nth 1 (s-split "@" host))
  106. host))))
  107. (defun dired-rsync--extract-user-from-tramp (file-or-path)
  108. "Extract the username part of a tramp FILE-OR-PATH."
  109. (when (s-contains? "@" file-or-path)
  110. (nth 1 (s-split ":" (nth 0 (s-split "@" file-or-path))))))
  111. (defun dired-rsync--extract-paths-from-tramp (files)
  112. "Extract the path part of a tramp FILES and quote it."
  113. (--map
  114. (let ((parts (s-split ":" it)))
  115. (shell-quote-argument (nth 2 parts)))
  116. files))
  117. ;; Update status with count/speed
  118. (defun dired-rsync--update-modeline (&optional err ind)
  119. "Update the modeline, optionally with `ERR' or `IND'.
  120. `ERR' is set this indicates a problem, otherwise `IND' is an
  121. alternative indication (such as a percentage completion). If
  122. neither is set we simply display the current number of jobs."
  123. (force-mode-line-update)
  124. (setq mode-line-process
  125. (setq dired-rsync-modeline-status
  126. (cond
  127. ;; error has occurred
  128. (err (propertize
  129. (format " R:%d %s!!" dired-rsync-job-count err)
  130. 'font-lock-face '(:foreground "red")))
  131. ;; we still have jobs but no error
  132. ((> dired-rsync-job-count 1)
  133. (format " R:%d" dired-rsync-job-count))
  134. ((> dired-rsync-job-count 0)
  135. (format " R:%s" (or ind dired-rsync-job-count)))
  136. ;; nothing going on
  137. (t nil)))))
  138. ;;
  139. ;; Running rsync: We need to take care of a couple of things here. We
  140. ;; need to ensure we run from the local host as you shouldn't expect
  141. ;; the remote target to be as aware of the ssh shortcuts home as from
  142. ;; the local system out (.ssh/config). We also want to track when it
  143. ;; is finished so we can inform the user the copy is complete.
  144. ;;
  145. (defun dired-rsync--sentinel(proc desc details)
  146. "Process sentinel for rsync processes.
  147. This gets called whenever the inferior `PROC' changes state as
  148. described by `DESC'. `DETAILS' provides access to additional
  149. information such as the locate of the dired-buffer."
  150. (let ((proc-buf (process-buffer proc)))
  151. (when (s-starts-with-p "finished" desc)
  152. ;; clean-up finished tasks
  153. (let ((dired-buf (plist-get details ':dired-buffer)))
  154. (when dired-rsync-unmark-on-completion
  155. (with-current-buffer dired-buf
  156. (dired-unmark-all-marks)))
  157. (kill-buffer proc-buf)))
  158. ;; clean-up data left from dead/finished processes
  159. (when (not (process-live-p proc))
  160. (setq dired-rsync-job-count (1- dired-rsync-job-count)))
  161. (dired-rsync--update-modeline)
  162. ;; If we still have a process buffer things didn't end well
  163. (when (and (not (process-live-p proc))
  164. (buffer-name proc-buf))
  165. (with-current-buffer proc-buf
  166. (run-hooks 'dired-rsync-failed-hook)))))
  167. (defun dired-rsync--filter (proc string details)
  168. "`PROC' rsync process filter, insert `STRING' into buffer with `DETAILS'.
  169. This gets called with string whenever there is new data to
  170. display in the process buffer. We scan the string to extract useful
  171. information and can use `DETAILS' to find and update the original
  172. dired-buffer modeline."
  173. ;; scan the new string
  174. (let ((err nil) (indicator nil))
  175. ;; Grab % complete string
  176. (when (string-match dired-rsync-percent-complete-regex string)
  177. (setq indicator (match-string 0 string)))
  178. ;; check for prompt
  179. (when (string-match dired-rsync-passphrase-stall-regex string)
  180. (process-send-string proc (concat (read-passwd string) "\n")))
  181. ;; update if anything to report
  182. (when (or err indicator)
  183. (with-current-buffer (plist-get details ':dired-buffer)
  184. (dired-rsync--update-modeline err indicator))))
  185. ;; update the process buffer (we could just drop?)
  186. (let ((old-process-mark (process-mark proc)))
  187. ;; do the normal buffer text insertion
  188. (when (buffer-live-p (process-buffer proc))
  189. (with-current-buffer (process-buffer proc)
  190. (let ((moving (= (point) old-process-mark)))
  191. (save-excursion
  192. ;; Insert the text, advancing the process marker.
  193. (goto-char old-process-mark)
  194. (insert string)
  195. (set-marker (process-mark proc) (point)))
  196. (if moving (goto-char (process-mark proc))))))))
  197. (defun dired-rsync--do-run (command details)
  198. "Run rsync COMMAND in a unique buffer, passing DETAILS to sentinel."
  199. (let* ((buf (format "*rsync @ %s" (current-time-string)))
  200. (proc (start-process-shell-command "*rsync*" buf command)))
  201. (set-process-sentinel
  202. proc
  203. #'(lambda (proc desc)
  204. (dired-rsync--sentinel proc desc details)))
  205. (set-process-filter
  206. proc
  207. #'(lambda (proc string)
  208. (dired-rsync--filter proc string details)))
  209. (setq dired-rsync-job-count (1+ dired-rsync-job-count))
  210. (dired-rsync--update-modeline)))
  211. (defun dired-rsync--remote-to-from-local-cmd (sfiles dest)
  212. "Construct a rsync command for SFILES to DEST copy.
  213. This handles both remote to local or local to remote copy.
  214. Fortunately both forms are broadly the same."
  215. (let ((src-files
  216. (-map 'dired-rsync--quote-and-maybe-convert-from-tramp sfiles))
  217. (final-dest (dired-rsync--quote-and-maybe-convert-from-tramp dest)))
  218. (s-join " "
  219. (-flatten
  220. (list dired-rsync-command
  221. dired-rsync-options
  222. src-files
  223. final-dest)))))
  224. ;; ref: https://unix.stackexchange.com/questions/183504/how-to-rsync-files-between-two-remotes
  225. (defun dired-rsync--remote-to-remote-cmd (shost sfiles duser dhost dpath)
  226. "Construct and trigger an rsync run for remote copy.
  227. The source SHOST and SFILES to remote DUSER @ DHOST to DPATH.
  228. rsync doesn't support this mode of operation but we can fake it by
  229. providing a port forward from the source host which we pass onto the
  230. destination. This requires ssh'ing to the source and running the rsync
  231. there."
  232. (s-join " " (-flatten
  233. (list "ssh" "-A"
  234. "-R" (format "localhost:50000:%s:22" dhost)
  235. shost
  236. (format
  237. "'%s %s -e \"%s\" %s %s@localhost:%s'"
  238. dired-rsync-command
  239. dired-rsync-options
  240. "ssh -p 50000 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
  241. (s-join " " sfiles)
  242. duser
  243. dpath)))))
  244. ;;;###autoload
  245. (defun dired-rsync (dest)
  246. "Asynchronously copy files in dired to `DEST' using rsync.
  247. `DEST' can be a relative filename and will be processed by
  248. `expand-file-name' before being passed to the rsync command.
  249. This function runs the copy asynchronously so Emacs won't block whilst
  250. the copy is running. It also handles both source and destinations on
  251. ssh/scp tramp connections."
  252. ;; Interactively grab dest if not called with
  253. (interactive
  254. (list (read-file-name "rsync to:" (dired-dwim-target-directory)
  255. nil nil nil 'file-directory-p)))
  256. (setq dest (expand-file-name dest))
  257. (let ((sfiles (funcall dired-rsync-source-files))
  258. (cmd))
  259. (setq cmd
  260. (if (and (tramp-tramp-file-p dest)
  261. (tramp-tramp-file-p (-first-item sfiles)))
  262. (let ((shost (dired-rsync--extract-host-from-tramp (-first-item sfiles)))
  263. (src-files (dired-rsync--extract-paths-from-tramp sfiles))
  264. (dhost (dired-rsync--extract-host-from-tramp dest t))
  265. (duser (dired-rsync--extract-user-from-tramp dest))
  266. (dpath (-first-item (dired-rsync--extract-paths-from-tramp (list dest)))))
  267. (dired-rsync--remote-to-remote-cmd shost src-files
  268. duser dhost dpath))
  269. (dired-rsync--remote-to-from-local-cmd sfiles dest)))
  270. (dired-rsync--do-run cmd
  271. (list :marked-files sfiles
  272. :dired-buffer (buffer-name)))))
  273. (provide 'dired-rsync)
  274. ;;; dired-rsync.el ends here