├── .gitignore ├── justfile ├── pyproject.toml ├── README.md └── edir.py /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | .idea/ 4 | .vscode/ 5 | *.egg-info/ 6 | __pycache__/ 7 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | PYFILES := file_name(justfile_dir()) + '.py' 2 | 3 | check: 4 | ruff check {{PYFILES}} 5 | ty check --python /usr/bin/python {{PYFILES}} 6 | vermin -vv --no-tips -i {{PYFILES}} 7 | md-link-checker 8 | 9 | build: 10 | rm -rf dist 11 | uv build 12 | 13 | upload: build 14 | uv-publish 15 | 16 | doc: 17 | update-readme-usage 18 | 19 | format: 20 | ruff check --select I --fix {{PYFILES}} && ruff format {{PYFILES}} 21 | 22 | clean: 23 | @rm -vrf *.egg-info build/ dist/ __pycache__/ 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools-scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "edir" 7 | description = "Utility to rename, remove, and copy files/dirs using your editor" 8 | readme = "README.md" 9 | license = "GPL-3.0-or-later" 10 | requires-python = ">=3.8" 11 | keywords = ["vidir", "git", "trash", "trash-put", "thrash-d"] 12 | classifiers = [ 13 | "Programming Language :: Python :: 3", 14 | ] 15 | dynamic = ["version"] 16 | dependencies = [ 17 | "argparse-from-file", 18 | ] 19 | 20 | [[project.authors]] 21 | name = "Mark Blakeney" 22 | email = "mark.blakeney@bullet-systems.net" 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/bulletmark/edir" 26 | 27 | [project.scripts] 28 | edir = "edir:main" 29 | 30 | [tool.setuptools_scm] 31 | version_scheme = "post-release" 32 | 33 | [tool.mypy] 34 | implicit_optional = true 35 | warn_no_return = false 36 | allow_untyped_globals = true 37 | allow_redefinition = true 38 | 39 | [tool.ruff.format] 40 | quote-style = "single" 41 | skip-magic-trailing-comma = true 42 | 43 | [tool.edit-lint] 44 | linters = [ 45 | "ruff check", 46 | "mypy", 47 | "pyright", 48 | ] 49 | 50 | # vim:se sw=2: 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## EDIR - Rename, Remove, and Copy Files and Directories Using Your Editor 2 | [![PyPi](https://img.shields.io/pypi/v/edir)](https://pypi.org/project/edir/) 3 | [![AUR](https://img.shields.io/aur/version/edir)](https://aur.archlinux.org/packages/edir/) 4 | 5 | [`edir`][edir] is a command line utility to rename, remove, and copy 6 | filenames and directories using your text editor. Run it in the current 7 | directory and `edir` will open your editor on a list of files and 8 | directories in that directory. Each item in the directory will appear on 9 | its own numbered line. These numbers are how `edir` keeps track of what 10 | items are changed. Delete lines to remove files/directories, edit lines 11 | to rename files/directories, or duplicate line numbers to copy 12 | files/directories. You can also switch pairs of numbers to swap files or 13 | directories. If run from within a [Git](https://git-scm.com/) 14 | repository, `edir` will [use 15 | Git](#renames-and-deletes-in-a-git-repository) to rename or remove 16 | tracked files/directories. You can use a [trash program](#using-trash) 17 | to remove files. 18 | 19 | The latest version and documentation is available at 20 | https://github.com/bulletmark/edir. 21 | 22 | ## Advantages Compared to Vidir 23 | 24 | [`edir`][edir] unashamedly mimics the functionality of the 25 | [vidir](https://man.archlinux.org/man/extra/moreutils/vidir.1.en) utility from 26 | [moreutils](https://joeyh.name/code/moreutils/) but aims to improve it in the 27 | following ways: 28 | 29 | 1. `edir` automatically uses `git mv` instead of `mv` and `git rm` 30 | instead of `rm` for tracked files when invoked within a 31 | [Git](https://git-scm.com/) repository. There is also a `-G/--no-git` 32 | option to suppress this default action. See the description in the 33 | section below about [git options](#renames-and-deletes-in-a-git-repository). 34 | 35 | 2. `vidir` presents file and directories equivalently but `edir` adds a 36 | trailing slash `/` to visually discriminate directories. E.g. if `afile` and 37 | `bfile` are files, `adir` and `bdir` are directories, then `vidir` 38 | presents these in your editor as follows. 39 | 40 | ``` 41 | 1 ./a 42 | 2 ./b 43 | 3 ./c 44 | 4 ./d 45 | ``` 46 | 47 | But `edir` presents these as: 48 | 49 | ``` 50 | 1 ./a 51 | 2 ./b 52 | 3 ./c/ 53 | 4 ./d/ 54 | ``` 55 | 56 | Note the trailing slash is only for presentation in your editor. You 57 | are not required to ensure it is present after editing. E.g. editing 58 | line 3 above to `./e` (or even just to `e`) would still rename the 59 | directory `c` to `e`. 60 | 61 | Note also, that both `edir` and `vidir` show the leading `./` on each 62 | entry so that any leading spaces on the filename are clearly seen, 63 | and can be edited. 64 | 65 | 3. `edir` adds the ability to copy files or directories one or more 66 | times when you duplicate a numbered line (after the original). 67 | `vidir` does not have copy functionality. 68 | 69 | 4. `edir` allows you to remove a file/directory by deleting the line, as 70 | `vidir` does, but you can also remove it by pre-pending a `#` to 71 | "comment it out" or by substituting an entirely blank line. 72 | 73 | 5. By default, `edir` prints remove, rename, and copy messages whereas 74 | `vidir` prints messages only when the `-v/--verbose` switch is added. 75 | You can add `-q/--quiet` to `edir` to suppress these messages. 76 | 77 | 6. `edir` outputs messages in color. Remove messages are red, rename 78 | messages are yellow, and copy messages are green. You can choose to 79 | disable colored output. 80 | 81 | 7. When `vidir` is run with the `-v/--verbose` switch then it reports 82 | the renaming of original to intermediate temporary to final files if 83 | files are swapped etc. That is rather an implementation detail so 84 | `edir` only reports the original to final renames which is all the 85 | user really cares about. 86 | 87 | 8. To remove a large recursive tree you must pipe the directory tree to 88 | `vidir` and then explicitly remove all children files and directories 89 | before deleting a parent directory. You can do this also in `edir` of 90 | course (and arguably it is probably the safest approach) but there 91 | are times when you really want to let `edir` remove recursively so 92 | `edir` adds a `-r/--recurse` switch to allow this. BE CAREFUL USING 93 | THIS! 94 | 95 | 9. `vidir` always shows all files and directories in a directory, 96 | including hidden files and directories (i.e. those starting with a 97 | `.`). Usually a user does not want to be bothered with these so 98 | `edir` by default does not show them. They can be included by adding 99 | the `-a/--all` switch. 100 | 101 | 10. `edir` does not require the user to specify the `-` if something has 102 | been piped to standard input. E.g. you need only type `find | edir` 103 | as opposed to `find | edir -`. Note that `vidir` requires the second 104 | form. 105 | 106 | 11. `edir` adds a [`-i/--interactive` option](#previewing-changes) to 107 | show pending changes and prompt the user before actioning them. You 108 | can also choose to re-edit the changes. 109 | 110 | 12. `edir` adds a `-F/--files` option to only show files, or `-D/--dirs` 111 | to only show directories. 112 | 113 | 13. `edir` adds a `-L/--nolinks` option to ignore symbolic links. 114 | 115 | 14. `edir` adds a `-d/--depth` option to edit to the specified directory 116 | depth. The default is 1 so `edir a` (if a is a directory) will edit 117 | names to `a/*`, `edir -d2 a` will edit names to `a/*/*`, etc. `edir 118 | -d0 a` will just edit the `a` name directly. Can specify `-d -1` to 119 | edit to all depths (or use a large positive number). 120 | 121 | 15. `edir` adds a [`-t/--trash` option](#using-trash) to remove to your 122 | [Trash][trash]. 123 | By default this option invokes 124 | [`trash-put`](https://www.mankier.com/1/trash-put) from the 125 | [trash-cli](https://github.com/andreafrancia/trash-cli) package to 126 | do deletions but you can specify any alternative trash program, see 127 | [section below](#using-trash). 128 | 129 | 16. `edir` adds `-N/--sort-name, -M/--sort-time, -S/--sort-size` options 130 | to sort the paths when listed in your editor. There is also a 131 | `-E/--sort-reverse` option to reverse the order. 132 | 133 | 17. `edir` adds `-X/--group-dirs-first` and `-Y/--group-dirs-last` 134 | options to display directories grouped together, either first or 135 | last. These can be combined with the above sorting options. 136 | 137 | 18. `edir` shows a message "No files or directories" if there is nothing 138 | to edit, rather than opening an empty file as `vidir` does. 139 | 140 | 19. `edir` filters out any duplicate paths you may inadvertently specify 141 | on it's command line. 142 | 143 | 20. `edir` always invokes a consistent duplicate renaming scheme. E.g. if 144 | you rename `b`, `c`, `d` all to the same pre-existing name `a` then 145 | `edir` will rename `b` to `a~`, `c` to `a~1`, `d` to `a~2`. 146 | Depending on order of operations, `vidir` is not always consistent 147 | about this, E.g. sometimes it creates a `a~1` with no `a~` (this may 148 | be a bug in `vidir` that nobody has ever bothered to 149 | report/address?). 150 | 151 | 21. `edir` creates the temporary editing file with a `.sh` suffix so 152 | your EDITOR may syntax highlight the entries. Optionally, you can 153 | change this default suffix. 154 | 155 | 22. `edir` provides an optional environment value to add custom options 156 | to the invocation of your editor. See [section 157 | below](#edir_editor-environment-variable). 158 | 159 | 23. `edir` provides an optional configuration file to set default `edir` 160 | command line options. See [section below](#command-default-options). 161 | 162 | 24. Contrary to what it's name implies, `vidir` actually respects your 163 | `$EDITOR` variable and runs your preferred editor like `edir` does 164 | but `edir` has been given a generic name to make this more apparent. 165 | If `$EDITOR` is not set then `edir` uses a default editor 166 | appropriate to your system. 167 | 168 | 25. `vidir` returns status code 0 if all files successful, or 1 if any 169 | error. `edir` returns 0 if all files successful, 1 if some had 170 | error, or 2 if all had error. 171 | 172 | 26. `vidir` returns an error when attempting to rename across different 173 | file systems, which `edir` allows. 174 | 175 | 27. `edir` always ensures editor line numbers have the same width (e.g. 176 | `1` to `6` for 6 files, or `01` to `12` for 12 files, etc) so that 177 | file names always line up justified. This facilitates block editing 178 | of file names, e.g. using vim's [visual block 179 | mode](https://linuxhint.com/vim-visual-block-mode/). `vidir` doesn't 180 | do this so file names can be jagged wrt each other which makes block 181 | editing awkward. 182 | 183 | 28. `edir` is very strict about the format of the lines you edit and 184 | immediately exits with an error message (before changing anything) 185 | if you format one of the lines incorrectly. All lines in the edited 186 | list: 187 | 188 | 1. Must start with a number and that number must be in range. 189 | 2. Must have at least one white space/tab after the number, 190 | 3. Must have a remaining valid path name. 191 | 4. Can start with a `#` or be completely blank to be considered the 192 | same as deleted. 193 | 194 | Note the final edited order of lines does not matter, only the first 195 | number value is used to match the newly edited line to the original 196 | line so an easy way to swap two file names is just to swap their 197 | numbers. 198 | 199 | 29. `edir` always actions files consistently. The sequence of 200 | operations applied is: 201 | 202 | 1. Deleted files are removed and all renamed files and directories 203 | are renamed to temporaries. The temporaries are made on the same 204 | file-system as the target. 205 | 206 | 2. Empty deleted directories are removed. 207 | 208 | 3. Renamed temporary files and directories are renamed to their 209 | target name. Any required copies are created. 210 | 211 | 4. Remaining deleted directories are removed. 212 | 213 | In simple terms, remember that files are processed before 214 | directories so you can rename files into a different directory and 215 | then delete the original directory, all in one edit. However in 216 | practice it is far **less confusing and less risky** if you perform 217 | complicated renames and moves in distinct steps. 218 | 219 | ## Renames and Deletes in a GIT Repository 220 | 221 | When working within a [Git](https://git-scm.com/) repository, you nearly 222 | always want to use `git mv` instead of `mv` and `git rm` instead of `rm` 223 | for files and directories so `edir` recognises this and does it 224 | automatically. Note that only tracked files/dirs are moved or renamed 225 | using Git. Untracked files/dirs within the repository are removed or 226 | renamed in the normal way. 227 | 228 | If for some reason you don't want automatic git action then you can use 229 | the `-G/--no-git` option temporarily, or set it a default option. See 230 | the section below on how to set [default 231 | options](#command-default-options). If you set `--no-git` as the 232 | default, then you can use `-g/-git` on the command line to turn that 233 | default option off temporarily and re-enable git functionality. 234 | 235 | ## Using Trash 236 | 237 | Given how easy `edir` facilitates deleting files, some users may prefer to 238 | remove them to system [Trash][trash] from where they can be later listed and/or 239 | recovered. Specifying `-t/--trash` does this by executing the 240 | [`trash-put`](https://www.mankier.com/1/trash-put) command, from the 241 | [`trash-cli`](https://github.com/andreafrancia/trash-cli) package, to remove 242 | files rather than removing them natively. 243 | 244 | You may want to set `-t/--trash` as a default option. If you do so then 245 | you can use `-T` on the command line to turn that default option off 246 | temporarily. 247 | 248 | You can specify an alternative trash program, e.g. 249 | [`trash-d`](https://github.com/rushsteve1/trash-d), or 250 | [`gio trash`](https://man.archlinux.org/man/gio.1#COMMANDS), or 251 | [`gtrash put`](https://github.com/umlx5h/gtrash), 252 | by setting the `--trash-program` option. Most likely you 253 | want to set this as a [default option](#command-default-options). 254 | 255 | ## Previewing Changes 256 | 257 | Many users would like to see a preview of changes after they finish 258 | editing but before they are actioned by `edir`, i.e. to confirm exactly 259 | which files/dirs will be deleted, renamed, or copied. Add the 260 | `-i/--interactive` option and `edir` will present a list of changes and 261 | prompt you to continue, or allow you to re-edit the path list etc. 262 | Consider setting `--interactive` as a [default 263 | option](#command-default-options) so you are always prompted. 264 | 265 | After a preview of pending changes is shown a prompt is presented for 266 | the user to enter a single key: 267 | 268 | `(P)roceed/(Y)es, (E)dit, (R)estart, (Q)uit[default]: [p|y|e|r|q]?` 269 | 270 | where: 271 | 272 | |Option |Key |Action| 273 | |--- |--- |---| 274 | |`Proceed/Yes`|`p` or `y`|Proceed with the path changes.| 275 | |`Edit` |`e` |Edit the path list again, as it is was last edited.| 276 | |`Restart` |`r` |Restart editing the path list again, as it originally began.| 277 | |`Quit` |`q` |Quit immediately without making any changes. This is the default if no key is entered.| 278 | 279 | ## Installation or Upgrade 280 | 281 | Python 3.8 or later is required. Arch Linux users can install [`edir` 282 | from the AUR](https://aur.archlinux.org/packages/edir) and skip this 283 | section. 284 | 285 | Note [`edir` is on PyPI](https://pypi.org/project/edir/) so the easiest 286 | way to install it is to use [`uv tool`][uvtool] (or [`pipx`][pipx] or 287 | [`pipxu`][pipxu]). 288 | 289 | ```sh 290 | $ uv tool install edir 291 | ``` 292 | 293 | To upgrade: 294 | 295 | ```sh 296 | $ uv tool upgrade edir 297 | ``` 298 | 299 | To uninstall: 300 | 301 | ```sh 302 | $ uv tool uninstall edir 303 | ``` 304 | 305 | [Git](https://git-scm.com/) must be installed if you want to use the git 306 | options. A trash program such as 307 | [trash-cli](https://github.com/andreafrancia/trash-cli) package is 308 | required if you want `-t/--trash` functionality. 309 | 310 | ### EDIR_EDITOR Environment Variable 311 | 312 | `edir` selects your editor from the first environment value found of: 313 | `$EDIR_EDITOR` or `$EDITOR`, then guesses a fallback default editor 314 | appropriate to your system if neither of these are set. 315 | 316 | You can also set `EDIR_EDITOR` explicitly to an editor + arguments 317 | string if you want `edir` to call your editor with specific arguments. 318 | 319 | ## Command Default Options 320 | 321 | You can add default options to a personal configuration file 322 | `~/.config/edir-flags.conf`. If that file exists then each line of 323 | options will be concatenated and automatically prepended to your `edir` 324 | command line arguments. Comments in the file (i.e. starting with a `#`) 325 | are ignored. Type `edir -h` to see all [supported 326 | options](#command-line-options). 327 | 328 | The options `--interactive`, `--all`, `--recurse`, `--quiet`, 329 | `--no-git`, `--trash`, `--suffix`, `--no-color`, `--no-invert-color`, 330 | `--group-dirs-first/last`, `--trash-program` are sensible candidates to 331 | consider setting as default. If you set these then "on-the-fly" negation 332 | options `-I`, `-A`, `-R`, `-Q`, `-g`, `-T`, `-Z` are also provided to 333 | temporarily override and disable default options on the command line. 334 | 335 | ## Examples 336 | 337 | Rename and/or remove any files and directories in the current directory: 338 | 339 | ```sh 340 | $ edir 341 | ``` 342 | 343 | Rename and/or remove any jpeg files in current dir: 344 | 345 | ```sh 346 | $ edir *.jpg 347 | ``` 348 | 349 | Rename and/or remove any files under current directory and subdirectories: 350 | 351 | ```sh 352 | $ find | edir -F 353 | ``` 354 | 355 | Use [`fd`](https://github.com/sharkdp/fd) to view and `git mv/rm` 356 | repository files only, in the current directory only: 357 | 358 | ```sh 359 | $ fd -d1 -tf | edir -g 360 | ``` 361 | 362 | ## Command Line Options 363 | 364 | Type `edir -h` to view the usage summary: 365 | 366 | ``` 367 | usage: edir [-h] [-i] [-I] [-a] [-A] [-r] [-R] [-q] [-Q] [-G] [-g] [-t] 368 | [-T] [--trash-program TRASH_PROGRAM] [-c] [-C] [-d DEPTH] [-F | 369 | -D] [-L] [-N] [-M] [-S] [-E] [-X] [-Y] [-Z] [--suffix SUFFIX] 370 | [-V] 371 | [args ...] 372 | 373 | Command line utility to rename, remove, or copy files and directories directly 374 | from your editor. When run, you'll see a numbered list of files in your 375 | editor. Delete a line to remove a file, modify a line to rename a file, or 376 | duplicate a line to copy a file. Swap line numbers to swap file names. If 377 | inside a git repository, git will be used for renames and removals. 378 | 379 | positional arguments: 380 | args file|dir, or "-" for stdin 381 | 382 | options: 383 | -h, --help show this help message and exit 384 | -i, --interactive prompt with summary of changes and allow re-edit 385 | before proceeding 386 | -I, --no-interactive negate the -i/--interactive option 387 | -a, --all include all (including hidden) files 388 | -A, --no-all negate the -a/--all option 389 | -r, --recurse recursively remove any files and directories in 390 | removed directories 391 | -R, --no-recurse negate the -r/--recurse option 392 | -q, --quiet do not print successful rename/remove/copy actions 393 | -Q, --no-quiet negate the -q/--quiet option 394 | -G, --no-git do not use git if invoked within a git repository 395 | -g, --git negate the --no-git option and DO use automatic git 396 | -t, --trash use trash program to do deletions 397 | -T, --no-trash negate the -t/--trash option 398 | --trash-program TRASH_PROGRAM 399 | trash program to use, default="trash-put" 400 | -c, --no-color do not color rename/remove/copy messages 401 | -C, --no-invert-color 402 | do not invert the color to highlight error messages 403 | -d, --depth DEPTH edit paths to specified depth, default=1 404 | -F, --files only show/edit files 405 | -D, --dirs only show/edit directories 406 | -L, --nolinks ignore all symlinks 407 | -N, --sort-name sort paths in file by name, alphabetically 408 | -M, --sort-time sort paths in file by time, oldest first 409 | -S, --sort-size sort paths in file by size, smallest first 410 | -E, --sort-reverse sort paths (by name/time/size) in reverse 411 | -X, --group-dirs-first 412 | group directories first (including when sorted) 413 | -Y, --group-dirs-last 414 | group directories last (including when sorted) 415 | -Z, --no-group-dirs negate the options to group directories 416 | --suffix SUFFIX specify suffix for temp editor file, default=".sh" 417 | -V, --version show edir version 418 | 419 | Note you can set default starting options in $HOME/.config/edir- 420 | flags.conf. The negation options (i.e. the --no-* options) allow you to 421 | temporarily override your defaults. 422 | ``` 423 | 424 | ## Running with sudo 425 | 426 | You can use `edir` with [`sudo`][sudo] to rename, delete, or copy system files. 427 | For improved convenience and security, `edir` runs the editing session as your 428 | regular user, not as root - similar to how [`sudoedit`][sudoedit] works. This 429 | approach allows you to use graphical editors (like VS Code), which should not be 430 | run as root. Use `sudo -E` so that your preferred editor is selected via your 431 | `$EDIR_EDITOR` or `$EDITOR` environment variable. For example, to rename or 432 | delete files in `/etc` using VS Code: 433 | 434 | ```sh 435 | $ export EDIR_EDITOR="code -nw" 436 | $ sudo -E edir /etc 437 | ``` 438 | 439 | ## Embed in Ranger File Manager 440 | 441 | In many ways `edir` (and even `vidir`) is better than the [`ranger`][ranger] 442 | terminal file manager 443 | [`bulkrename`](https://github.com/ranger/ranger/wiki/Official-user-guide#bulk-renaming) 444 | command which does not handle name swaps and clashes etc. To add `edir` as a 445 | command within [`ranger`][ranger], add or create the following in 446 | `~/.config/ranger/commands.py`. Then run it from within [`ranger`][ranger] by 447 | typing `:edir`. 448 | 449 | ```python 450 | from ranger.api.commands import Command 451 | 452 | class edir(Command): 453 | ''' 454 | :edir [file|dir] 455 | 456 | Run edir on the selected file or dir. 457 | Default argument is current dir. 458 | ''' 459 | def execute(self): 460 | self.fm.run('edir -q ' + self.rest(1)) 461 | def tab(self, tabnum): 462 | return self._tab_directory_content() 463 | ``` 464 | 465 | ## Use with Yazi File Manager 466 | 467 | If you use [`yazi`][yazi] for your file manager then you don't need any special 468 | configuration. Just type `:edir` from within [`yazi`][yazi]. 469 | 470 | ## License 471 | 472 | Copyright (C) 2019 Mark Blakeney. This program is distributed under the 473 | terms of the GNU General Public License. This program is free software: 474 | you can redistribute it and/or modify it under the terms of the GNU 475 | General Public License as published by the Free Software Foundation, 476 | either version 3 of the License, or any later version. This program is 477 | distributed in the hope that it will be useful, but WITHOUT ANY 478 | WARRANTY; without even the implied warranty of MERCHANTABILITY or 479 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 480 | License at for more details. 481 | 482 | [edir]: https://github.com/bulletmark/edir 483 | [edirpy]: https://pypi.org/project/edir 484 | [pipx]: https://github.com/pypa/pipx 485 | [pipxu]: https://github.com/bulletmark/pipxu 486 | [uvtool]: https://docs.astral.sh/uv/guides/tools/#installing-tools 487 | [ranger]: https://ranger.github.io/ 488 | [yazi]: https://yazi-rs.github.io/ 489 | [trash]: https://www.freedesktop.org/wiki/Specifications/trash-spec/ 490 | [sudo]: https://man7.org/linux/man-pages/man8/sudo.8.html 491 | [sudoedit]: https://man7.org/linux/man-pages/man8/sudoedit.8.html 492 | 493 | 494 | -------------------------------------------------------------------------------- /edir.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Command line utility to rename, remove, or copy files and directories directly 4 | from your editor. When run, you'll see a numbered list of files in your editor. 5 | Delete a line to remove a file, modify a line to rename a file, or duplicate a 6 | line to copy a file. Swap line numbers to swap file names. If inside a git 7 | repository, git will be used for renames and removals. 8 | """ 9 | 10 | # Author: Mark Blakeney, May 2019. 11 | from __future__ import annotations 12 | 13 | import itertools 14 | import os 15 | import shlex 16 | import shutil 17 | import subprocess 18 | import sys 19 | import tempfile 20 | from collections.abc import Callable, Iterable, Sequence 21 | from pathlib import Path 22 | 23 | import argparse_from_file as argparse 24 | 25 | # Some constants 26 | PROG = Path(sys.argv[0]).stem 27 | EDITOR = PROG.upper() + '_EDITOR' 28 | SUFFIX = '.sh' 29 | SEP = os.sep 30 | 31 | # The temp dir we will use in the dir of each target move 32 | TEMPDIR = '.tmp-' + PROG 33 | 34 | # Define action verbs and ANSI escape sequences for action colors 35 | # Refer https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 36 | ACTIONS = { 37 | 'remove': ( 38 | ('\033[31m', '\033[30;41m'), # Red 39 | ('Removing', 'Remove', 'Removed'), 40 | ), 41 | 'copy': ( 42 | ('\033[32m', '\033[30;42m'), # Green 43 | ('Copying', 'Copy', 'Copied'), 44 | ), 45 | 'rename': ( 46 | ('\033[33m', '\033[30;43m'), # Yellow 47 | ('Renaming', 'Rename', 'Renamed'), 48 | ), 49 | } 50 | 51 | COLOR_reset = '\033[39;49m' 52 | 53 | args = argparse.Namespace() 54 | gitfiles = set() 55 | counts = [0, 0] 56 | 57 | EDITORS = {'Windows': 'notepad', 'Darwin': 'open -e', 'default': 'vim'} 58 | 59 | 60 | def get_default_editor() -> str: 61 | "Return default editor for this system" 62 | from platform import system 63 | 64 | return EDITORS.get(system(), EDITORS['default']) 65 | 66 | 67 | def make_editor_command(filename: str, sudo_user: str) -> tuple[str, list[str]]: 68 | "Create the edit command" 69 | editcmd = ['runuser', '-u', sudo_user, '--'] if sudo_user else [] 70 | 71 | # Use explicit user defined editor or choose system default 72 | editor = ( 73 | os.getenv(EDITOR) 74 | or (sudo_user and os.getenv('SUDO_EDITOR')) 75 | or os.getenv('EDITOR') 76 | or get_default_editor() 77 | ) 78 | 79 | editcmd.extend(shlex.split(editor)) 80 | editcmd.append(filename) 81 | return editor, editcmd 82 | 83 | 84 | def editfile(editor: str, editcmd: list[str]) -> None: 85 | "Run the editor command" 86 | 87 | # Run the editor .. 88 | if sys.stdin.isatty(): 89 | res = subprocess.run(editcmd) 90 | else: 91 | with open('/dev/tty') as tty: 92 | res = subprocess.run(editcmd, stdin=tty) 93 | 94 | # Check if editor returned error 95 | if res.returncode != 0: 96 | sys.exit(f'ERROR: "{editor}" returned error {res.returncode}') 97 | 98 | 99 | def log( 100 | action: str, msg: str, error: str | None = None, *, prompt: bool = False 101 | ) -> None: 102 | "Output message with appropriate color" 103 | counts[bool(error)] += 1 104 | 105 | colors, tense = ACTIONS[action] 106 | 107 | if error: 108 | out = sys.stderr 109 | msg = f'{tense[1]} {msg} ERROR: {error}' 110 | elif args.quiet and not prompt: 111 | return 112 | else: 113 | out = sys.stdout 114 | msg = f'{tense[0] if prompt else tense[2]} {msg}' 115 | 116 | if not args.no_color: 117 | msg = colors[bool(error and not args.no_invert_color)] + msg + COLOR_reset 118 | 119 | print(msg, file=out) 120 | 121 | 122 | def run(cmd: Sequence[str]) -> tuple[str, str]: 123 | "Run given command and return (stdout, stderr) strings" 124 | stdout = '' 125 | stderr = '' 126 | try: 127 | res = subprocess.run( 128 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True 129 | ) 130 | except Exception as e: 131 | stderr = str(e) 132 | else: 133 | if res.stdout: 134 | stdout = res.stdout.strip() 135 | if res.stderr: 136 | stderr = res.stderr.strip() 137 | 138 | return stdout, stderr 139 | 140 | 141 | def remove( 142 | path: Path, git: bool = False, trash: bool = False, recurse: bool = False 143 | ) -> str | None: 144 | "Delete given file/directory" 145 | if not recurse and not path.is_symlink() and path.is_dir() and any(path.iterdir()): 146 | return 'Directory not empty' 147 | 148 | if git: 149 | cmd = 'git rm -f'.split() 150 | if recurse: 151 | cmd.append('-r') 152 | _, err = run(cmd + [str(path)]) 153 | return f'git error: {err}' if err else None 154 | 155 | if trash: 156 | _, err = run(args.trash_program + [str(path)]) 157 | return f'{shlex.join(args.trash_program)} error: {err}' if err else None 158 | 159 | if recurse and not path.is_symlink(): 160 | try: 161 | shutil.rmtree(str(path)) 162 | except Exception as e: 163 | return str(e) 164 | else: 165 | try: 166 | if not path.is_symlink() and path.is_dir(): 167 | path.rmdir() 168 | else: 169 | path.unlink() 170 | except Exception as e: 171 | return str(e) 172 | 173 | 174 | def rename(pathsrc: Path, pathdest: Path, is_git: bool = False) -> str: 175 | "Rename given pathsrc to pathdest" 176 | if is_git: 177 | _, err = run('git mv -f'.split() + [str(pathsrc), str(pathdest)]) 178 | if err: 179 | err = f'git mv error: {err}' 180 | else: 181 | try: 182 | shutil.move(str(pathsrc), str(pathdest)) 183 | except Exception as e: 184 | # Remove any trailing colon and specific file since we may 185 | # be renaming a temp file. 186 | err = str(e).split(':')[0] 187 | else: 188 | err = '' 189 | 190 | return err 191 | 192 | 193 | def create_prompt(options: str) -> str: 194 | "Create a string of options + keys for user to choose from" 195 | letters = [] 196 | words = [] 197 | for arg in options.split(): 198 | subwords = [] 199 | for subarg in arg.split('/'): 200 | letters.append(subarg[0]) 201 | subwords.append(f'({subarg[0].upper()}){subarg[1:]}') 202 | words.append('/'.join(subwords)) 203 | return ', '.join(words) + ': [' + '|'.join(letters) + ']? ' 204 | 205 | 206 | def find_in_dir(path: Path, depth: int, allfiles: bool) -> Iterable[Path]: 207 | "Find files/dirs in given path" 208 | if depth == 0: 209 | yield path 210 | return 211 | 212 | for p in sorted(path.iterdir()): 213 | if allfiles or not p.name.startswith('.'): 214 | if not p.is_dir() or depth == 1: 215 | yield p 216 | else: 217 | yield from find_in_dir(p, depth - 1, allfiles) 218 | 219 | 220 | class Fpath: 221 | "Class to manage each instance of a file/dir" 222 | 223 | paths = [] 224 | tempdirs = set() 225 | 226 | def __init__(self, path: Path): 227 | "Class constructor" 228 | self.path = path 229 | self.newpath = None 230 | self.temppath = None 231 | self.note = '' 232 | self.copies: list[Path] = [] 233 | try: 234 | self.is_dir = path.is_dir() and not path.is_symlink() 235 | except Exception as e: 236 | sys.exit(f'ERROR: Can not read {path}: {e}') 237 | 238 | self.appdash = SEP if self.is_dir else '' 239 | self.diagrepr = str(self.path) 240 | self.is_git = self.diagrepr in gitfiles 241 | 242 | self.linerepr = ( 243 | self.diagrepr if self.path.is_absolute() else f'.{SEP}{self.diagrepr}' 244 | ) 245 | if self.is_dir and not self.diagrepr.endswith(SEP): 246 | self.linerepr += SEP 247 | self.diagrepr += SEP 248 | 249 | @staticmethod 250 | def inc_path(path: Path) -> Path: 251 | "Find next unique file name" 252 | # Iterate forever, there can only be a finite number of existing 253 | # paths 254 | name = path.name 255 | for c in itertools.count(): 256 | if not path.is_symlink() and not path.exists(): 257 | break 258 | path = path.with_name(name + ('~' if c <= 0 else f'~{c}')) 259 | 260 | return path 261 | 262 | def copy(self, pathdest: Path) -> str | None: 263 | "Copy given pathsrc to pathdest" 264 | if self.is_dir: 265 | func: Callable = shutil.copytree 266 | else: 267 | func = shutil.copy2 268 | 269 | # copytree() will create the parent dir[s], but copy2() will not 270 | try: 271 | pathdest.parent.mkdir(parents=True, exist_ok=True) 272 | except Exception as e: 273 | return str(e) 274 | try: 275 | func(str(self.newpath), str(pathdest)) # type:ignore 276 | except Exception as e: 277 | return str(e) 278 | 279 | def rename_temp(self) -> str | None: 280 | "Move this path to a temp place in advance of final move" 281 | if not self.newpath: 282 | return None 283 | tempdir = self.newpath.parent / TEMPDIR 284 | try: 285 | tempdir.mkdir(parents=True, exist_ok=True) 286 | except Exception as e: 287 | # Remove any trailing colon and specific file since we are 288 | # renaming a temp file. 289 | return str(e).split(':')[0] 290 | 291 | self.tempdirs.add(tempdir) 292 | temppath = self.inc_path(tempdir / self.newpath.name) 293 | if not (err := rename(self.path, temppath, self.is_git)): 294 | self.temppath = temppath 295 | 296 | return err 297 | 298 | def restore_temp(self) -> str | None: 299 | "Restore temp path to final destination" 300 | if not self.temppath or not self.newpath: 301 | return None 302 | self.newpath = self.inc_path(self.newpath) 303 | return rename(self.temppath, self.newpath, self.is_git) 304 | 305 | def sort_name(self) -> str: 306 | "Return name for sort" 307 | return str(self.path) 308 | 309 | def sort_time(self) -> float: 310 | "Return time for sort" 311 | try: 312 | ret = self.path.lstat().st_mtime 313 | except Exception: 314 | ret = 0 315 | 316 | return ret 317 | 318 | def sort_size(self) -> int: 319 | "Return size for sort" 320 | try: 321 | ret = self.path.lstat().st_size 322 | except Exception: 323 | ret = 0 324 | 325 | return ret 326 | 327 | def is_recursive(self) -> bool: 328 | "Return True if directory and we can view it and contains children" 329 | if not self.is_dir: 330 | return False 331 | try: 332 | return any(self.path.iterdir()) 333 | except Exception: 334 | return False 335 | 336 | def log_pending_changes(self) -> None: 337 | "Log all pending changes for this path" 338 | if not self.newpath: 339 | log('remove', f'"{self.diagrepr}"{self.note}', prompt=True) 340 | elif self.newpath != self.path: 341 | log( 342 | 'rename', 343 | f'"{self.diagrepr}" to "{self.newpath}{self.appdash}"', 344 | prompt=True, 345 | ) 346 | for c in self.copies: 347 | log( 348 | 'copy', 349 | f'"{self.diagrepr}" to "{c}{self.appdash}"{self.note}', 350 | prompt=True, 351 | ) 352 | 353 | @classmethod 354 | def remove_temps(cls) -> None: 355 | "Remove all the temp dirs we created in rename_temp() above" 356 | for p in cls.tempdirs: 357 | remove(p, git=False, trash=False, recurse=True) 358 | 359 | cls.tempdirs.clear() 360 | 361 | @classmethod 362 | def append(cls, path: Path) -> None: 363 | "Add a single file/dir to the list of paths" 364 | # Filter out files/dirs if asked 365 | if args.files: 366 | if path.is_dir(): 367 | return 368 | elif args.dirs: 369 | if not path.is_dir(): 370 | return 371 | 372 | # Filter out links if asked 373 | if args.nolinks and path.is_symlink(): 374 | return 375 | 376 | cls.paths.append(cls(path)) 377 | 378 | @classmethod 379 | def add(cls, name: str, depth: int) -> None: 380 | "Add file[s]/dir[s] to the list of paths" 381 | path = Path(name) 382 | if not path.exists(): 383 | sys.exit(f'ERROR: {name} does not exist') 384 | 385 | if path.is_dir(): 386 | for child in find_in_dir(path, depth, args.all): 387 | cls.append(child) 388 | else: 389 | cls.append(path) 390 | 391 | @classmethod 392 | def writefile(cls, fpath: Path) -> None: 393 | "Write the file for user to edit" 394 | with fpath.open('w') as fp: 395 | # Ensure consistent width for line numbers 396 | num_width = len(str(len(cls.paths))) 397 | fp.writelines( 398 | f'{i:0{num_width}} {p.linerepr}\n' for i, p in enumerate(cls.paths, 1) 399 | ) 400 | 401 | @classmethod 402 | def readfile(cls, fpath: Path) -> None: 403 | "Read the list of files/dirs as edited by user" 404 | # Reset all the read path changes to null state 405 | for p in cls.paths: 406 | p.newpath = None 407 | p.copies.clear() 408 | 409 | # Now read file and record changes 410 | with fpath.open() as fp: 411 | for count, line in enumerate(fp, 1): 412 | # Skip blank or commented lines 413 | rawline = line.rstrip('\n\r') 414 | line = rawline.lstrip() 415 | if not line or line[0] == '#': 416 | continue 417 | 418 | try: 419 | n, pathstr = line.split(maxsplit=1) 420 | except Exception: 421 | sys.exit(f'ERROR: line {count} invalid:\n{rawline}') 422 | try: 423 | num = int(n) 424 | except Exception: 425 | sys.exit(f'ERROR: line {count} number {n} invalid:\n{rawline}') 426 | 427 | if num <= 0 or num > len(cls.paths): 428 | sys.exit( 429 | f'ERROR: line {count} number {num} out of range:\n{rawline}' 430 | ) 431 | 432 | path = cls.paths[num - 1] 433 | 434 | if len(pathstr) > 1: 435 | pathstr = pathstr.rstrip(SEP) 436 | 437 | newpath = Path(pathstr) 438 | 439 | if path.newpath: 440 | if newpath != path.path and newpath not in path.copies: 441 | path.copies.append(newpath) 442 | else: 443 | path.newpath = newpath 444 | 445 | @classmethod 446 | def get_path_changes(cls) -> list: 447 | "Get a list of change paths from the user" 448 | prompt = ( 449 | create_prompt('proceed/yes edit restart quit[default]') 450 | if args.interactive 451 | else None 452 | ) 453 | 454 | sudo_user = os.getenv('SUDO_USER', '') 455 | 456 | with tempfile.TemporaryDirectory() as fdir: 457 | if sudo_user: 458 | shutil.chown(fdir, sudo_user) 459 | 460 | # Create a temp file for the user to edit then read the lines back 461 | fpath = Path(fdir, f'{PROG}{args.suffix}') 462 | 463 | # Get the editor and editor command 464 | editor, editcmd = make_editor_command(str(fpath), sudo_user) 465 | 466 | restart = True 467 | while True: 468 | if restart: 469 | restart = False 470 | cls.writefile(fpath) 471 | 472 | if sudo_user: 473 | shutil.chown(fpath, sudo_user) 474 | 475 | # Invoke editor on file containing the list of paths 476 | editfile(editor, editcmd) 477 | 478 | # Read the changed paths from the file 479 | cls.readfile(fpath) 480 | 481 | # Reduce paths to those that were removed or changed by the user 482 | paths = [p for p in cls.paths if p.path != p.newpath or p.copies] 483 | 484 | if not paths: 485 | return [] 486 | 487 | # Lazy eval the next path value 488 | for p in paths: 489 | p.note = ' recursively' if p.is_recursive() else '' 490 | 491 | if not prompt: 492 | return paths 493 | 494 | # Prompt user with pending changes if required 495 | for p in paths: 496 | p.log_pending_changes() 497 | 498 | while True: 499 | try: 500 | ans = input(prompt).strip().lower() 501 | except KeyboardInterrupt: 502 | print() 503 | return [] 504 | 505 | if not ans or ans == 'q': 506 | return [] 507 | elif ans in 'py': 508 | return paths 509 | elif ans == 'e': 510 | break 511 | elif ans == 'r': 512 | restart = True 513 | break 514 | else: 515 | print(f'Invalid answer "{ans}".') 516 | 517 | 518 | def main() -> int: 519 | "Main code" 520 | global args 521 | # Process command line options 522 | opt = argparse.ArgumentParser( 523 | description=__doc__, 524 | epilog='Note you can set default starting options in ' 525 | '#FROM_FILE_PATH#. The negation options (i.e. the --no-* options) ' 526 | 'allow you to temporarily override your defaults.', 527 | ) 528 | opt.add_argument( 529 | '-i', 530 | '--interactive', 531 | action='store_true', 532 | help='prompt with summary of changes and allow re-edit before proceeding', 533 | ) 534 | opt.add_argument( 535 | '-I', 536 | '--no-interactive', 537 | dest='interactive', 538 | action='store_false', 539 | help='negate the -i/--interactive option', 540 | ) 541 | opt.add_argument( 542 | '-a', '--all', action='store_true', help='include all (including hidden) files' 543 | ) 544 | opt.add_argument( 545 | '-A', 546 | '--no-all', 547 | dest='all', 548 | action='store_false', 549 | help='negate the -a/--all option', 550 | ) 551 | opt.add_argument( 552 | '-r', 553 | '--recurse', 554 | action='store_true', 555 | help='recursively remove any files and directories in removed directories', 556 | ) 557 | opt.add_argument( 558 | '-R', 559 | '--no-recurse', 560 | dest='recurse', 561 | action='store_false', 562 | help='negate the -r/--recurse option', 563 | ) 564 | opt.add_argument( 565 | '-q', 566 | '--quiet', 567 | action='store_true', 568 | help='do not print successful rename/remove/copy actions', 569 | ) 570 | opt.add_argument( 571 | '-Q', 572 | '--no-quiet', 573 | dest='quiet', 574 | action='store_false', 575 | help='negate the -q/--quiet option', 576 | ) 577 | opt.add_argument( 578 | '-G', 579 | '--no-git', 580 | dest='git', 581 | action='store_const', 582 | const=0, 583 | help='do not use git if invoked within a git repository', 584 | ) 585 | opt.add_argument( 586 | '-g', 587 | '--git', 588 | dest='git', 589 | action='store_const', 590 | const=1, 591 | help='negate the --no-git option and DO use automatic git', 592 | ) 593 | opt.add_argument( 594 | '-t', '--trash', action='store_true', help='use trash program to do deletions' 595 | ) 596 | opt.add_argument( 597 | '-T', 598 | '--no-trash', 599 | dest='trash', 600 | action='store_false', 601 | help='negate the -t/--trash option', 602 | ) 603 | opt.add_argument( 604 | '--trash-program', 605 | default='trash-put', 606 | help='trash program to use, default="%(default)s"', 607 | ) 608 | opt.add_argument( 609 | '-c', 610 | '--no-color', 611 | action='store_true', 612 | help='do not color rename/remove/copy messages', 613 | ) 614 | opt.add_argument( 615 | '-C', 616 | '--no-invert-color', 617 | action='store_true', 618 | help='do not invert the color to highlight error messages', 619 | ) 620 | opt.add_argument( 621 | '-d', 622 | '--depth', 623 | type=int, 624 | default=1, 625 | help='edit paths to specified depth, default=%(default)d', 626 | ) 627 | grp = opt.add_mutually_exclusive_group() 628 | grp.add_argument('-F', '--files', action='store_true', help='only show/edit files') 629 | grp.add_argument( 630 | '-D', '--dirs', action='store_true', help='only show/edit directories' 631 | ) 632 | opt.add_argument('-L', '--nolinks', action='store_true', help='ignore all symlinks') 633 | opt.add_argument( 634 | '-N', 635 | '--sort-name', 636 | dest='sort', 637 | action='store_const', 638 | const=1, 639 | help='sort paths in file by name, alphabetically', 640 | ) 641 | opt.add_argument( 642 | '-M', 643 | '--sort-time', 644 | dest='sort', 645 | action='store_const', 646 | const=2, 647 | help='sort paths in file by time, oldest first', 648 | ) 649 | opt.add_argument( 650 | '-S', 651 | '--sort-size', 652 | dest='sort', 653 | action='store_const', 654 | const=3, 655 | help='sort paths in file by size, smallest first', 656 | ) 657 | opt.add_argument( 658 | '-E', 659 | '--sort-reverse', 660 | action='store_true', 661 | help='sort paths (by name/time/size) in reverse', 662 | ) 663 | opt.add_argument( 664 | '-X', 665 | '--group-dirs-first', 666 | dest='group_dirs', 667 | action='store_const', 668 | const=1, 669 | help='group directories first (including when sorted)', 670 | ) 671 | opt.add_argument( 672 | '-Y', 673 | '--group-dirs-last', 674 | dest='group_dirs', 675 | action='store_const', 676 | const=0, 677 | help='group directories last (including when sorted)', 678 | ) 679 | opt.add_argument( 680 | '-Z', 681 | '--no-group-dirs', 682 | dest='group_dirs', 683 | action='store_const', 684 | const=-1, 685 | help='negate the options to group directories', 686 | ) 687 | opt.add_argument( 688 | '--suffix', 689 | default=SUFFIX, 690 | help='specify suffix for temp editor file, default="%(default)s"', 691 | ) 692 | opt.add_argument( 693 | '-V', '--version', action='store_true', help=f'show {PROG} version' 694 | ) 695 | opt.add_argument('args', nargs='*', help='file|dir, or "-" for stdin') 696 | 697 | args = opt.parse_args() 698 | 699 | if args.version: 700 | from importlib.metadata import version 701 | 702 | try: 703 | ver = version(PROG) 704 | except Exception: 705 | ver = 'unknown' 706 | 707 | print(ver) 708 | return 0 709 | 710 | if args.trash: 711 | if not args.trash_program: 712 | opt.error('must specify trash program with --trash-program option') 713 | 714 | args.trash_program = args.trash_program.split() 715 | 716 | # Check if we are in a git repo 717 | if args.git != 0: 718 | out, giterr = run(('git', 'ls-files')) 719 | if giterr and args.git: 720 | print(f'Git invocation error: {giterr}', file=sys.stderr) 721 | if out: 722 | gitfiles.update(out.splitlines()) 723 | 724 | if args.git and not gitfiles: 725 | opt.error('must be within a git repo to use -g/--git option') 726 | 727 | # Set input list to a combination of arguments and stdin 728 | filelist = args.args 729 | if sys.stdin.isatty(): 730 | if not filelist: 731 | filelist.append('.') 732 | elif '-' not in filelist: 733 | filelist.insert(0, '-') 734 | 735 | # Iterate over all (unique) inputs to get a list of files/dirs 736 | for name in dict.fromkeys(filelist): 737 | if name == '-': 738 | for line in sys.stdin: 739 | Fpath.add(line.rstrip('\n\r'), 0) 740 | else: 741 | Fpath.add(name, args.depth) 742 | 743 | # Sanity check that we have something to edit 744 | if not Fpath.paths: 745 | desc = ( 746 | 'files' 747 | if args.files 748 | else 'directories' 749 | if args.dirs 750 | else 'files or directories' 751 | ) 752 | print(f'No {desc}.') 753 | return 0 754 | 755 | if args.sort == 1: 756 | Fpath.paths.sort(key=Fpath.sort_name, reverse=args.sort_reverse) 757 | elif args.sort == 2: 758 | Fpath.paths.sort(key=Fpath.sort_time, reverse=args.sort_reverse) 759 | elif args.sort == 3: 760 | Fpath.paths.sort(key=Fpath.sort_size, reverse=args.sort_reverse) 761 | 762 | if args.group_dirs is not None and args.group_dirs >= 0: 763 | ldirs: list[Fpath] = [] 764 | lfiles: list[Fpath] = [] 765 | for path in Fpath.paths: 766 | (ldirs if path.is_dir else lfiles).append(path) 767 | Fpath.paths = ldirs + lfiles if args.group_dirs else lfiles + ldirs 768 | 769 | paths = Fpath.get_path_changes() 770 | if not paths: 771 | return 0 772 | 773 | err: str | None 774 | 775 | # Pass 1: Rename all moved files & dirs to temps, delete all removed 776 | # files. 777 | for p in paths: 778 | if p.newpath: 779 | if p.newpath != p.path: 780 | if err := p.rename_temp(): 781 | log('rename', f'"{p.diagrepr}" to "{p.newpath}{p.appdash}"', err) 782 | elif not p.is_dir: 783 | err = remove(p.path, p.is_git, args.trash) 784 | log('remove', f'"{p.diagrepr}"', err) 785 | 786 | # Pass 2: Delete all removed dirs, if empty or recursive delete. 787 | for p in paths: 788 | if p.is_dir and not p.newpath: 789 | if remove(p.path, p.is_git, args.trash, args.recurse) is None: 790 | # Have removed, so flag as finished for final dirs pass below 791 | p.is_dir = False 792 | log('remove', f'"{p.diagrepr}"{p.note}') 793 | 794 | # Pass 3. Rename all temp files and dirs to final target, and make 795 | # copies. 796 | for p in paths: 797 | if (err := p.restore_temp()) is not None: 798 | log('rename', f'"{p.diagrepr}" to "{p.newpath}{p.appdash}"', err) 799 | 800 | for c in p.copies: 801 | err = p.copy(c) 802 | log('copy', f'"{p.diagrepr}" to "{c}{p.appdash}"{p.note}', err) 803 | 804 | # Remove all the temporary dirs we created 805 | Fpath.remove_temps() 806 | 807 | # Pass 4. Delete all remaining dirs 808 | for p in paths: 809 | if p.is_dir and not p.newpath: 810 | err = remove(p.path, p.is_git, args.trash, args.recurse) 811 | log('remove', f'"{p.diagrepr}"{p.note}', err) 812 | 813 | # Return status code 0 = all good, 1 = some bad, 2 = all bad. 814 | return (1 if counts[0] > 0 else 2) if counts[1] > 0 else 0 815 | 816 | 817 | if __name__ == '__main__': 818 | sys.exit(main()) 819 | --------------------------------------------------------------------------------