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.

352 lines
13 KiB

  1. ;;; dired-rsync.el --- Allow rsync from dired buffers -*- lexical-binding: t -*-
  2. ;;
  3. ;; Copyright (C) 2018, 2019, 2020 Alex Bennée
  4. ;;
  5. ;; Author: Alex Bennée <alex@bennee.com>
  6. ;; Maintainer: Alex Bennée <alex@bennee.com>
  7. ;; Version: 0.6
  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-proc-buffer-prefix "*rsync"
  80. "Prefix for process buffers.")
  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. (defvar dired-remote-portfwd
  91. "ssh -p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
  92. "An explicit ssh command for rsync to use port forwarded proxy.
  93. The string is treated as a format string where %d is replaced with the
  94. results of `dired-rsync--get-remote-port'.")
  95. ;; Helpers
  96. (defun dired-rsync--get-remote-port ()
  97. "Return the remote port we shall use for the reverse port-forward."
  98. (+ 50000 (length (dired-rsync--get-active-buffers))))
  99. (defun dired-rsync--get-remote-portfwd ()
  100. (format dired-remote-portfwd (dired-rsync--get-remote-port)))
  101. (defun dired-rsync--quote-and-maybe-convert-from-tramp (file-or-path)
  102. "Reformat a tramp FILE-OR-PATH to one usable for rsync."
  103. (if (tramp-tramp-file-p file-or-path)
  104. (with-parsed-tramp-file-name file-or-path tfop
  105. (format "%s%s:\"%s\"" (if tfop-user (format "%s@" tfop-user) "") tfop-host
  106. (shell-quote-argument tfop-localname)))
  107. (shell-quote-argument file-or-path)))
  108. (defun dired-rsync--extract-host-from-tramp (file-or-path &optional split-user)
  109. "Extract the tramp host part of FILE-OR-PATH.
  110. It SPLIT-USER is set we remove the user@ part as well. We assume
  111. hosts don't need quoting."
  112. (with-parsed-tramp-file-name file-or-path tfop
  113. (if (or split-user (not tfop-user))
  114. tfop-host
  115. (format "%s@%s" tfop-user tfop-host))))
  116. ;; This is tricky for remote-to-remote because we may have an implied
  117. ;; user from the local config which isn't available on the remote
  118. ;; .ssh/config
  119. (defun dired-rsync--extract-user-from-tramp (file-or-path)
  120. "Extract the username part of a tramp FILE-OR-PATH."
  121. (with-parsed-tramp-file-name file-or-path tfop
  122. (or tfop-user
  123. ; somehow extract .ssh/config user for tfop-host
  124. (getenv "USER"))))
  125. (defun dired-rsync--extract-paths-from-tramp (files)
  126. "Extract the path part of a tramp FILES and quote it."
  127. (--map
  128. (with-parsed-tramp-file-name it tfop
  129. (shell-quote-argument tfop-localname))
  130. files))
  131. ;; Count active buffers
  132. (defun dired-rsync--get-proc-buffers ()
  133. "Return all dired-rsync process buffers."
  134. (--filter
  135. (and (s-starts-with? dired-rsync-proc-buffer-prefix (buffer-name it))
  136. (get-buffer-process it))
  137. (buffer-list)))
  138. (defun dired-rsync--get-active-buffers ()
  139. "Return all currently active process buffers"
  140. (--filter
  141. (process-live-p (get-buffer-process it))
  142. (dired-rsync--get-proc-buffers)))
  143. ;; Update status with count/speed
  144. (defun dired-rsync--update-modeline (&optional err ind)
  145. "Update the modeline, optionally with `ERR' or `IND'.
  146. `ERR' is set this indicates a problem, otherwise `IND' is an
  147. alternative indication (such as a percentage completion). If
  148. neither is set we simply display the current number of jobs."
  149. (force-mode-line-update)
  150. (let ((job-count (length (dired-rsync--get-active-buffers))))
  151. (setq dired-rsync-modeline-status
  152. (cond
  153. ;; error has occurred
  154. (err (propertize
  155. (format " R:%d %s!!" job-count err)
  156. 'font-lock-face '(:foreground "red")))
  157. ;; we still have jobs but no error
  158. ((> job-count 1)
  159. (format " R:%d" job-count))
  160. ((> job-count 0)
  161. (format " R:%s" (or ind job-count)))
  162. ;; Any stale?
  163. ((dired-rsync--get-proc-buffers)
  164. (propertize
  165. " R:hung :-("
  166. 'font-lock-face '(:foreground "red")))
  167. ;; nothing going on
  168. (t nil)))))
  169. ;;
  170. ;; Running rsync: We need to take care of a couple of things here. We
  171. ;; need to ensure we run from the local host as you shouldn't expect
  172. ;; the remote target to be as aware of the ssh shortcuts home as from
  173. ;; the local system out (.ssh/config). We also want to track when it
  174. ;; is finished so we can inform the user the copy is complete.
  175. ;;
  176. (defun dired-rsync--sentinel(proc desc details)
  177. "Process sentinel for rsync processes.
  178. This gets called whenever the inferior `PROC' changes state as
  179. described by `DESC'. `DETAILS' provides access to additional
  180. information such as the locate of the dired-buffer."
  181. (let ((proc-buf (process-buffer proc)))
  182. (when (s-starts-with-p "finished" desc)
  183. ;; clean-up finished tasks
  184. (let ((dired-buf (plist-get details ':dired-buffer)))
  185. (when (and dired-rsync-unmark-on-completion
  186. (buffer-live-p dired-buf))
  187. (with-current-buffer dired-buf
  188. (dired-unmark-all-marks)))
  189. (kill-buffer proc-buf)))
  190. (dired-rsync--update-modeline)
  191. ;; If we still have a process buffer things didn't end well
  192. (when (and (not (process-live-p proc))
  193. (buffer-name proc-buf))
  194. (with-current-buffer proc-buf
  195. (run-hooks 'dired-rsync-failed-hook)))))
  196. (defun dired-rsync--filter (proc string details)
  197. "`PROC' rsync process filter, insert `STRING' into buffer with `DETAILS'.
  198. This gets called with string whenever there is new data to
  199. display in the process buffer. We scan the string to extract useful
  200. information and can use `DETAILS' to find and update the original
  201. dired-buffer modeline."
  202. ;; scan the new string
  203. (let ((err nil) (indicator nil))
  204. ;; Grab % complete string
  205. (when (string-match dired-rsync-percent-complete-regex string)
  206. (setq indicator (match-string 0 string)))
  207. ;; check for prompt
  208. (when (string-match dired-rsync-passphrase-stall-regex string)
  209. (process-send-string proc (concat (read-passwd string) "\n")))
  210. ;; update if anything to report
  211. (when (or err indicator)
  212. (dired-rsync--update-modeline err indicator)))
  213. ;; update the process buffer (we could just drop?)
  214. (let ((old-process-mark (process-mark proc)))
  215. ;; do the normal buffer text insertion
  216. (when (buffer-live-p (process-buffer proc))
  217. (with-current-buffer (process-buffer proc)
  218. (let ((moving (= (point) old-process-mark)))
  219. (save-excursion
  220. ;; Insert the text, advancing the process marker.
  221. (goto-char old-process-mark)
  222. (insert string)
  223. (set-marker (process-mark proc) (point)))
  224. (if moving (goto-char (process-mark proc))))))))
  225. (defun dired-rsync--do-run (command details)
  226. "Run rsync COMMAND in a unique buffer, passing DETAILS to sentinel."
  227. (let* ((buf (format "%s @ %s" dired-rsync-proc-buffer-prefix (current-time-string)))
  228. (proc (start-process-shell-command "*rsync*" buf command)))
  229. (set-process-sentinel
  230. proc
  231. #'(lambda (proc desc)
  232. (dired-rsync--sentinel proc desc details)))
  233. (set-process-filter
  234. proc
  235. #'(lambda (proc string)
  236. (dired-rsync--filter proc string details)))
  237. (dired-rsync--update-modeline)))
  238. (defun dired-rsync--remote-to-from-local-cmd (sfiles dest)
  239. "Construct a rsync command for SFILES to DEST copy.
  240. This handles both remote to local or local to remote copy.
  241. Fortunately both forms are broadly the same."
  242. (let ((src-files
  243. (-map 'dired-rsync--quote-and-maybe-convert-from-tramp sfiles))
  244. (final-dest (dired-rsync--quote-and-maybe-convert-from-tramp dest)))
  245. (s-join " "
  246. (-flatten
  247. (list dired-rsync-command
  248. dired-rsync-options
  249. src-files
  250. final-dest)))))
  251. ;; ref: https://unix.stackexchange.com/questions/183504/how-to-rsync-files-between-two-remotes
  252. (defun dired-rsync--remote-to-remote-cmd (shost sfiles duser dhost dpath)
  253. "Construct and trigger an rsync run for remote copy.
  254. The source SHOST and SFILES to remote DUSER @ DHOST to DPATH.
  255. rsync doesn't support this mode of operation but we can fake it by
  256. providing a port forward from the source host which we pass onto the
  257. destination. This requires ssh'ing to the source and running the rsync
  258. there."
  259. (s-join " " (-flatten
  260. (list "ssh" "-A"
  261. "-R" (format "localhost:%d:%s:22"
  262. (dired-rsync--get-remote-port) dhost)
  263. shost
  264. (format
  265. "\"%s %s -e \\\"%s\\\" %s %s@localhost:%s\""
  266. dired-rsync-command
  267. dired-rsync-options
  268. (dired-rsync--get-remote-portfwd)
  269. (s-join " " sfiles)
  270. duser
  271. dpath)))))
  272. ;;;###autoload
  273. (defun dired-rsync (dest)
  274. "Asynchronously copy files in dired to `DEST' using rsync.
  275. `DEST' can be a relative filename and will be processed by
  276. `expand-file-name' before being passed to the rsync command.
  277. This function runs the copy asynchronously so Emacs won't block whilst
  278. the copy is running. It also handles both source and destinations on
  279. ssh/scp tramp connections."
  280. ;; Interactively grab dest if not called with
  281. (interactive
  282. (list (read-file-name "rsync to: " (dired-dwim-target-directory)
  283. nil nil nil 'file-directory-p)))
  284. (setq dest (expand-file-name dest))
  285. (let ((sfiles (funcall dired-rsync-source-files))
  286. (cmd))
  287. (setq cmd
  288. (if (and (tramp-tramp-file-p dest)
  289. (tramp-tramp-file-p (-first-item sfiles)))
  290. (let ((shost (dired-rsync--extract-host-from-tramp (-first-item sfiles)))
  291. (src-files (dired-rsync--extract-paths-from-tramp sfiles))
  292. (dhost (dired-rsync--extract-host-from-tramp dest t))
  293. (duser (dired-rsync--extract-user-from-tramp dest))
  294. (dpath (-first-item (dired-rsync--extract-paths-from-tramp (list dest)))))
  295. (dired-rsync--remote-to-remote-cmd shost src-files
  296. duser dhost dpath))
  297. (dired-rsync--remote-to-from-local-cmd sfiles dest)))
  298. (dired-rsync--do-run cmd
  299. (list :marked-files sfiles
  300. :dired-buffer (buffer-name)))))
  301. (provide 'dired-rsync)
  302. ;;; dired-rsync.el ends here