Compare commits

..

67 commits
v0.1.0 ... main

Author SHA1 Message Date
itchyny
cf6f0458b7 bump up version to 0.1.6 2023-04-26 19:00:01 +09:00
itchyny
e40bbff081 update CHANGELOG.md for v0.1.6 2023-04-26 18:54:19 +09:00
itchyny
7c8adbbb4c split EDITOR in Go to remove dependency on sh (ref #19) 2023-04-21 22:12:21 +09:00
itchyny
f627dff093 bump up version to 0.1.5 2023-04-09 12:49:00 +09:00
itchyny
834e2a759b update CHANGELOG.md for v0.1.5 2023-04-09 12:37:48 +09:00
itchyny
03ad35008b update copyright year in LICENSE 2023-04-09 12:37:48 +09:00
itchyny
97dd98cb16 update dependencies 2023-04-09 12:37:48 +09:00
itchyny
6a82806d14 migrate from deprecated create-release action 2023-04-09 12:37:48 +09:00
itchyny
467ba873c3 support EDITOR with spaces in the editor path (close #19, ref #10) 2023-04-09 12:37:37 +09:00
itchyny
fec2c9b389 improve Makefile 2023-04-09 12:27:02 +09:00
itchyny
e81d936dd5 switch from math/rand to crypto/rand package 2023-04-09 12:25:22 +09:00
itchyny
49d37b4903 add package comment and enable all checks of staticcheck 2023-04-09 12:23:33 +09:00
itchyny
8d31a98aa7 switch from deprecated io/ioutil to os package 2023-04-09 12:22:40 +09:00
itchyny
0d6dba4de7 update GitHub action dependencies 2023-04-09 12:20:54 +09:00
itchyny
d6ee613353 specify minimum permission for the default token in workflows 2023-04-09 12:19:53 +09:00
itchyny
c44dfa5098 bump up version to 0.1.4 2021-09-18 23:51:42 +09:00
itchyny
76a6036242 update CHANGELOG.md for v0.1.4 2021-09-18 23:51:11 +09:00
itchyny
ba9f0ed094 update dependencies 2021-09-18 23:50:38 +09:00
itchyny
e392ca34a8 switch the default branch to main 2021-09-18 23:48:00 +09:00
itchyny
f1bebac133 improve make cross to build arm64 artifacts 2021-09-18 23:46:57 +09:00
itchyny
a4eae94d71 switch to staticcheck from golint 2021-09-18 23:46:17 +09:00
itchyny
57b18aecf5 add -race flag to go test 2021-09-18 23:46:06 +09:00
itchyny
f749335cf8 remove setting GO111MODULE in Makefile 2021-09-18 23:45:47 +09:00
itchyny
535165d72f use go install instead of go get 2021-09-18 23:45:25 +09:00
itchyny
bf465879e8 require Go 1.16+ 2021-09-18 23:44:41 +09:00
itchyny
3db7e0409a use https url of the license badge 2021-09-18 23:42:42 +09:00
itchyny
a14e35541e bump up version to 0.1.3 2021-01-10 09:20:31 +09:00
itchyny
fc12f280f9 update CHANGELOG.md for v0.1.3 2021-01-10 09:18:45 +09:00
itchyny
87ca5d0a07 update dependencies 2021-01-10 09:16:09 +09:00
itchyny
226133344a close temporary file before starting editor 2021-01-10 09:16:08 +09:00
itchyny
5ee3f57dff initialize seed of the global random generator using unix timestamp
The algorithm for generating temporary paths is not cryptographically safe,
but this is enough in real situation.
2021-01-08 20:09:10 +09:00
itchyny
5ac7f7d75f use strconv.FormatUint with base 16 to generate temporary paths 2021-01-08 19:43:52 +09:00
itchyny
85ce23caff avoid fmt.Sprintf in error messages 2021-01-08 19:20:48 +09:00
itchyny
885b5d6aa0 report an error on failed to create a temporary path 2021-01-07 22:23:24 +09:00
itchyny
f208800bec improve comments 2021-01-07 22:12:34 +09:00
itchyny
dca2fae7c1 update copyright year in LICENSE 2021-01-07 21:55:25 +09:00
itchyny
4ce13f9e9e support directory renames (close #11)
- reject invalid renames (ex: move x x/x)
- move to a temporary path before any parent directory is moved
- rename in increasing destination directory depth order
2021-01-07 21:42:34 +09:00
itchyny
9e2bfa58df fix the version of actions 2020-12-30 21:55:22 +09:00
itchyny
de90991b35 fix links in CHANGELOG.md 2020-12-05 21:00:58 +09:00
itchyny
6be6420b38 bump up version to 0.1.2 2020-09-19 11:23:30 +09:00
itchyny
d86799e9b7 add CHANGELOG.md 2020-09-19 11:16:37 +09:00
itchyny
ec4f47cf17 include CREDITS file in artifacts 2020-09-19 10:57:00 +09:00
itchyny
8b1a6fae22 update dependencies 2020-09-19 10:55:44 +09:00
itchyny
0c6928a9dc update .gitignore 2020-09-18 19:54:18 +09:00
itchyny
87d6ba6a14 update badges in README.md 2020-09-18 19:53:46 +09:00
itchyny
485dd72d9e
Merge pull request #10 from tgfjt/master 2020-09-18 19:48:30 +09:00
Takashi Fujita
fce83774d2 fix to use strings.Fields to split 2020-09-18 19:40:02 +09:00
Takashi Fujita
e21be145b5 fix to pass EDITOR command with args 2020-09-15 15:54:16 +09:00
itchyny
f854148e5f use actions/checkout main 2020-08-16 00:45:44 +09:00
itchyny
a25a89e162 use t.Cleanup instead of defer 2020-08-14 01:31:47 +09:00
itchyny
cbe026840a remove dependency on github.com/stretchr/testify 2020-08-14 01:31:44 +09:00
itchyny
10d2c971a8 apply gofumpt 2020-08-14 01:16:58 +09:00
itchyny
c040426d6d remove clean from make all 2020-07-20 21:25:24 +09:00
itchyny
c0e9cd0f3d update CI triggers not to duplicate on pull requests 2020-05-07 15:46:56 +09:00
itchyny
e5da254c11 use actions/setup-go v2 2020-05-07 15:46:36 +09:00
itchyny
9b54f8bfac update gobump path 2020-01-24 21:36:45 +09:00
itchyny
fd22f52662 name all the steps in GitHub Actions 2020-01-16 20:45:38 +09:00
itchyny
b0bf7b16c3 use add-path for adding $GOPATH/bin to $PATH 2020-01-16 19:30:12 +09:00
itchyny
b9a4d40943 bump up version to 0.1.1 2020-01-09 21:39:27 +09:00
itchyny
407f4d2b0c
Merge pull request #6 from lunarxlark/master
fix typo
2020-01-09 17:52:08 +09:00
lunarxlark
fade350461 fix typo 2020-01-09 17:49:04 +09:00
itchyny
ec271f179b update feature list in README.md 2020-01-09 14:59:15 +09:00
itchyny
eab8aa1635 add a screenshot gif to README.md 2020-01-09 14:40:52 +09:00
itchyny
0ed17e263c undo the processed renames on error 2020-01-09 13:08:26 +09:00
itchyny
2a878af8f0 change error in tests to string 2020-01-09 13:02:51 +09:00
itchyny
42b640ffe3 test the file contents even on failure cases 2020-01-09 12:08:07 +09:00
itchyny
a4647e9205 return the stat error on failure 2020-01-09 12:07:26 +09:00
12 changed files with 462 additions and 187 deletions

View file

@ -1,6 +1,13 @@
name: CI
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
test:
@ -10,15 +17,13 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@master
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v1
uses: actions/setup-go@v4
with:
go-version: 1.x
- name: Test
run: make test
- name: Lint
run: |
export PATH=$PATH:$(go env GOPATH)/bin # will be removed
make lint
if: matrix.os != 'windows-latest'
run: make lint

View file

@ -5,32 +5,24 @@ on:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@master
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v1
uses: actions/setup-go@v4
with:
go-version: 1.x
- name: Cross build
run: |
export PATH=$PATH:$(go env GOPATH)/bin # remove when actions/setup-go does this
make cross
run: make cross
- name: Create Release
id: create_release
uses: actions/create-release@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ncipollo/release-action@v1
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
- name: Upload
run: |
export PATH=$PATH:$(go env GOPATH)/bin # remove when actions/setup-go does this
make upload
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Release ${{ github.ref_name }}
artifacts: 'goxz/*'

6
.gitignore vendored
View file

@ -1,2 +1,6 @@
/goxz
/mmv
/goxz
/CREDITS
*.exe
*.test
*.out

22
CHANGELOG.md Normal file
View file

@ -0,0 +1,22 @@
# Changelog
## [v0.1.6](https://github.com/itchyny/mmv/compare/v0.1.5..v0.1.6) (2023-04-26)
* Remove dependency on shell for splitting EDITOR with spaces.
## [v0.1.5](https://github.com/itchyny/mmv/compare/v0.1.4..v0.1.5) (2023-04-09)
* Support EDITOR with spaces in the editor path.
## [v0.1.4](https://github.com/itchyny/mmv/compare/v0.1.3..v0.1.4) (2021-09-18)
* Release `arm64` artifacts.
## [v0.1.3](https://github.com/itchyny/mmv/compare/v0.1.2..v0.1.3) (2021-01-10)
* Support renaming when one of the paths is a parent directory of another.
## [v0.1.2](https://github.com/itchyny/mmv/compare/v0.1.1..v0.1.2) (2020-09-19)
* Fix for EDITOR configured with arguments.
## [v0.1.1](https://github.com/itchyny/mmv/compare/v0.1.0..v0.1.1) (2020-01-09)
* Undo the processed renames on error not to leave the temporary file.
* Return the stat source error on failure.
## [v0.1.0](https://github.com/itchyny/mmv/compare/ac88fa9...v0.1.0) (2020-01-08)
* Initial implementation.

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2020 itchyny
Copyright (c) 2020-2023 itchyny
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,13 +1,12 @@
BIN := mmv
VERSION := $$(make -s show-version)
VERSION_PATH := cmd/$(BIN)
CURRENT_REVISION := $(shell git rev-parse --short HEAD)
BUILD_LDFLAGS := "-s -w -X main.revision=$(CURRENT_REVISION)"
CURRENT_REVISION = $(shell git rev-parse --short HEAD)
BUILD_LDFLAGS = "-s -w -X main.revision=$(CURRENT_REVISION)"
GOBIN ?= $(shell go env GOPATH)/bin
export GO111MODULE=on
.PHONY: all
all: clean build
all: build
.PHONY: build
build:
@ -15,56 +14,51 @@ build:
.PHONY: install
install:
go install -ldflags=$(BUILD_LDFLAGS) ./...
go install -ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN)
.PHONY: show-version
show-version: $(GOBIN)/gobump
@gobump show -r $(VERSION_PATH)
@gobump show -r "$(VERSION_PATH)"
$(GOBIN)/gobump:
@cd && go get github.com/motemen/gobump/cmd/gobump
@go install github.com/x-motemen/gobump/cmd/gobump@latest
.PHONY: cross
cross: $(GOBIN)/goxz
cross: $(GOBIN)/goxz CREDITS
goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN)
$(GOBIN)/goxz:
cd && go get github.com/Songmu/goxz/cmd/goxz
go install github.com/Songmu/goxz/cmd/goxz@latest
CREDITS: $(GOBIN)/gocredits go.sum
go mod tidy
gocredits -w .
$(GOBIN)/gocredits:
go install github.com/Songmu/gocredits/cmd/gocredits@latest
.PHONY: test
test: build
go test -v ./...
go test -v -race ./...
.PHONY: lint
lint: $(GOBIN)/golint
lint: $(GOBIN)/staticcheck
go vet ./...
golint -set_exit_status ./...
staticcheck -checks all ./...
$(GOBIN)/golint:
cd && go get golang.org/x/lint/golint
$(GOBIN)/staticcheck:
go install honnef.co/go/tools/cmd/staticcheck@latest
.PHONY: clean
clean:
rm -rf $(BIN) goxz
rm -rf $(BIN) goxz CREDITS
go clean
.PHONY: bump
bump: $(GOBIN)/gobump
ifneq ($(shell git status --porcelain),)
$(error git workspace is dirty)
endif
ifneq ($(shell git rev-parse --abbrev-ref HEAD),master)
$(error current branch is not master)
endif
test -z "$$(git status --porcelain || echo .)"
test "$$(git branch --show-current)" = "main"
@gobump up -w "$(VERSION_PATH)"
git commit -am "bump up version to $(VERSION)"
git tag "v$(VERSION)"
git push origin master
git push origin "refs/tags/v$(VERSION)"
.PHONY: upload
upload: $(GOBIN)/ghr
ghr "v$(VERSION)" goxz
$(GOBIN)/ghr:
cd && go get github.com/tcnksm/ghr
git push --atomic origin main tag "v$(VERSION)"

View file

@ -1,5 +1,14 @@
# mmv [![CI Status](https://github.com/itchyny/mmv/workflows/CI/badge.svg)](https://github.com/itchyny/mmv/actions)
# mmv
[![CI Status](https://github.com/itchyny/mmv/workflows/CI/badge.svg)](https://github.com/itchyny/mmv/actions)
[![Go Report Card](https://goreportcard.com/badge/github.com/itchyny/mmv)](https://goreportcard.com/report/github.com/itchyny/mmv)
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/itchyny/mmv/blob/main/LICENSE)
[![release](https://img.shields.io/github/release/itchyny/mmv/all.svg)](https://github.com/itchyny/mmv/releases)
[![pkg.go.dev](https://pkg.go.dev/badge/github.com/itchyny/mmv)](https://pkg.go.dev/github.com/itchyny/mmv)
Rename multiple files using your `$EDITOR`. The command name is named after _multi-mv_.
![](https://user-images.githubusercontent.com/375258/72040421-d4f8cd00-32eb-11ea-828f-d9f14f3261ac.gif)
## Usage
```bash
mmv file ...
@ -15,13 +24,14 @@ brew install itchyny/tap/mmv
### Build from source
```bash
go get github.com/itchyny/mmv/cmd/mmv
go install github.com/itchyny/mmv/cmd/mmv@latest
```
## Features
- `mmv` is implemented in Go language and completely portable.
- `mmv` supports renaming in cycle (`mv a b`, `mv b c` and `mv c a` at the same time).
- `mmv` is designed to be simple as `mv`. It requires no configuration file.
- `mmv` supports renaming in cycle (`mv a b`, `mv b c` and `mv c a` at the same time).
- `mmv` creates destination directories automatically. You can arrange pictures like `yyyy-mm-dd xxxx.jpg` to `yyyy/mm/dd/xxxx.jpg`.
- `mmv` is capable to use as a library (just call `mmv.Rename`).
- `mmv` is easy to remember (I believe), **m**ulti-**mv**.

View file

@ -4,12 +4,12 @@ import (
"errors"
"flag"
"fmt"
"io/ioutil"
"os"
"os/exec"
"runtime"
"strings"
"github.com/kballard/go-shellquote"
_ "github.com/mattn/getwild"
"github.com/mattn/go-tty"
@ -18,7 +18,7 @@ import (
const name = "mmv"
const version = "0.1.0"
const version = "0.1.6"
var revision = "HEAD"
@ -79,38 +79,45 @@ func rename(args []string) error {
}
xs[src] = true
}
f, err := ioutil.TempFile("", name+"-")
f, err := os.CreateTemp("", name+"-")
if err != nil {
return err
}
defer func() {
f.Close()
os.Remove(f.Name())
}()
defer os.Remove(f.Name())
for _, arg := range args {
f.WriteString(arg)
f.WriteString("\n")
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
if err = f.Close(); err != nil {
return err
}
tty, err := tty.Open()
if err != nil {
return err
}
defer tty.Close()
cmd := exec.Command(editor, f.Name())
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
editorWithArgs, err := shellquote.Split(editor)
if err != nil {
return fmt.Errorf("%s: %s", err, editor)
}
editorWithArgs = append(editorWithArgs, f.Name())
cmd := exec.Command(editorWithArgs[0], editorWithArgs[1:]...)
cmd.Stdin = tty.Input()
cmd.Stdout = tty.Output()
cmd.Stderr = tty.Output()
if err := cmd.Run(); err != nil {
if err = cmd.Run(); err != nil {
return fmt.Errorf("abort renames: %s", err)
}
if err := f.Close(); err != nil {
return err
}
cnt, err := ioutil.ReadFile(f.Name())
cnt, err := os.ReadFile(f.Name())
if err != nil {
return err
}
@ -122,5 +129,6 @@ func rename(args []string) error {
for i, src := range args {
files[src] = got[i]
}
return mmv.Rename(files)
}

13
go.mod
View file

@ -1,9 +1,14 @@
module github.com/itchyny/mmv
go 1.13
go 1.20
require (
github.com/mattn/getwild v0.0.1
github.com/mattn/go-tty v0.0.3
github.com/stretchr/testify v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/getwild v0.0.2-0.20200919000855-c2e221927ad6
github.com/mattn/go-tty v0.0.4
)
require (
github.com/mattn/go-isatty v0.0.18 // indirect
golang.org/x/sys v0.7.0 // indirect
)

31
go.sum
View file

@ -1,25 +1,18 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mattn/getwild v0.0.1 h1:+Nlzxt7fonj2MtO9y/rg5hxOnM3H6tuTqeD38W25jfo=
github.com/mattn/getwild v0.0.1/go.mod h1:AG+GKQydHp7iLJn+VV+D7y8LeYs5bQ0Xz4fmKd5o1Sg=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/getwild v0.0.2-0.20200919000855-c2e221927ad6 h1:uWR+2CTTaHQzDS/DApbJ2H8UEPQl90atrKtczXj2xcs=
github.com/mattn/getwild v0.0.2-0.20200919000855-c2e221927ad6/go.mod h1:AG+GKQydHp7iLJn+VV+D7y8LeYs5bQ0Xz4fmKd5o1Sg=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E=
github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e h1:N7DeIrjYszNmSW409R3frPPwglRwMkXSBzwVbkOjLLA=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

217
mmv.go
View file

@ -1,10 +1,12 @@
// Package mmv provides a method to rename multiple files.
package mmv
import (
"fmt"
"math/rand"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"strings"
)
// Rename multiple files.
@ -13,8 +15,16 @@ func Rename(files map[string]string) error {
if err != nil {
return err
}
for _, r := range rs {
for i, r := range rs {
if err := doRename(r.src, r.dst); err != nil {
// undo on error not to leave the temporary files
// this does not undo directory creation
for i--; i >= 0; i-- {
if r = rs[i]; os.Rename(r.dst, r.src) != nil {
// something wrong happens so give up not to overwrite files
break
}
}
return err
}
}
@ -27,14 +37,15 @@ func doRename(src, dst string) (err error) {
if err = os.Rename(src, dst); err != nil && os.IsNotExist(err) {
// check the source file existence to exit without creating the destination
// directory when the both source file and destination directory do not exist
if _, err := os.Stat(src); err == nil {
// create the destination directory
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
// try renaming again
return os.Rename(src, dst)
if _, err := os.Stat(src); err != nil {
return err
}
// create the destination directory
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
// try renaming again
return os.Rename(src, dst)
}
return
}
@ -54,7 +65,7 @@ type sameSourceError struct {
}
func (err *sameSourceError) Error() string {
return fmt.Sprintf("duplicate source: %s", err.path)
return "duplicate source: " + err.path
}
type sameDestinationError struct {
@ -62,7 +73,23 @@ type sameDestinationError struct {
}
func (err *sameDestinationError) Error() string {
return fmt.Sprintf("duplicate destination: %s", err.path)
return "duplicate destination: " + err.path
}
type invalidRenameError struct {
src, dst string
}
func (err *invalidRenameError) Error() string {
return "invalid rename: " + err.src + ", " + err.dst
}
type temporaryPathError struct {
dir string
}
func (err *temporaryPathError) Error() string {
return "failed to create a temporary path: " + err.dir
}
func buildRenames(files map[string]string) ([]rename, error) {
@ -95,76 +122,144 @@ func buildRenames(files map[string]string) ([]rename, error) {
if _, ok := revs[dst]; ok {
return nil, &sameDestinationError{dst}
}
if k, l := len(src), len(dst); k > l && src[l] == filepath.Separator && src[:l] == dst ||
k < l && dst[k] == filepath.Separator && dst[:k] == src {
return nil, &invalidRenameError{src, dst}
}
revs[dst] = src
}
// remove source == destination
// group paths by directory depth
srcdepths := make([][]string, 1)
dstdepths := make([][]string, 1)
for src, dst := range files {
if src == dst {
delete(files, src)
delete(revs, dst)
// group source paths by directory depth
i := strings.Count(src, string(filepath.Separator))
if len(srcdepths) <= i {
xs := make([][]string, i*2)
copy(xs, srcdepths)
srcdepths = xs
}
srcdepths[i] = append(srcdepths[i], src)
// group destination paths by directory depth
i = strings.Count(dst, string(filepath.Separator))
if len(dstdepths) <= i {
xs := make([][]string, i*2)
copy(xs, dstdepths)
dstdepths = xs
}
dstdepths[i] = append(dstdepths[i], dst)
}
// result renames
count := len(files)
rs := make([]rename, 0, 2*count)
// check if any parent directory will be moved
for i := len(srcdepths) - 1; i >= 0; i-- {
L:
for _, src := range srcdepths[i] {
for j := 0; j < i; j++ {
for _, s := range srcdepths[j] {
if k := len(s); len(src) > k && src[k] == filepath.Separator && src[:k] == s {
if d := files[s]; s != d {
if dst, l := files[src], len(d); i == j+1 && len(dst) > l && dst[:l] == d && dst[l:] == src[k:] {
// skip moving a file when it moves along with the closest parent directory
delete(files, src)
delete(revs, dst)
} else {
// move to a temporary path before any parent directory is moved
tmp, err := temporaryPath(filepath.Dir(s))
if err != nil {
return nil, err
}
rs = append(rs, rename{src, tmp})
files[tmp] = files[dst]
delete(files, src)
revs[dst] = tmp
}
continue L
}
}
}
}
// remove if source path is equal to destination path
if dst := files[src]; src == dst {
delete(files, src)
delete(revs, dst)
}
}
}
// list the renames
var i int
rs := make([]rename, 0, 2*len(files))
vs := make(map[string]int, len(files))
for _, dst := range files {
if vs[dst] > 0 {
continue
}
i++ // connected component identifier
// list renames in increasing destination directory depth order
i, vs := 0, make(map[string]int, count)
for _, dsts := range dstdepths {
for _, dst := range dsts {
if vs[dst] > 0 {
continue
}
i++ // connected component identifier
// mark the nodes in the connected component and check cycle
var cycle bool
for {
vs[dst] = i
if x, ok := files[dst]; ok {
dst = x
if vs[x] > 0 {
cycle = vs[x] == i
// mark the nodes in the connected component and check cycle
var cycle bool
for {
vs[dst] = i
if x, ok := files[dst]; ok {
dst = x
if vs[x] > 0 {
cycle = vs[x] == i
break
}
} else {
break
}
} else {
break
}
}
// if there is a cycle, rename to a temporary file
var tmp string
if cycle {
tmp = randomPath(filepath.Dir(dst))
rs = append(rs, rename{dst, tmp})
vs[dst]--
}
// rename from the leaf node
for {
if src, ok := revs[dst]; ok && (!cycle || vs[src] == i) {
rs = append(rs, rename{src, dst})
if !cycle {
vs[dst] = i
// if there is a cycle, rename to a temporary file
var tmp string
if cycle {
var err error
tmp, err = temporaryPath(filepath.Dir(dst))
if err != nil {
return nil, err
}
dst = src
} else {
break
rs = append(rs, rename{dst, tmp})
vs[dst]--
}
}
// if there is a cycle, rename the remporary file
if cycle {
rs = append(rs, rename{tmp, dst})
// rename from the leaf node
for {
if src, ok := revs[dst]; ok && (!cycle || vs[src] == i) {
rs = append(rs, rename{src, dst})
if !cycle {
vs[dst] = i
}
dst = src
} else {
break
}
}
// if there is a cycle, rename the temporary file
if cycle {
rs = append(rs, rename{tmp, dst})
}
}
}
return rs, nil
}
func randomPath(dir string) string {
for {
path := filepath.Join(dir, fmt.Sprint(rand.Uint64()))
// create a temporary path where there is no file currently
func temporaryPath(dir string) (string, error) {
bs := make([]byte, 16)
for i := 0; i < 256; i++ {
if _, err := rand.Read(bs); err != nil {
return "", err
}
path := filepath.Join(dir, base64.RawURLEncoding.EncodeToString(bs))
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
return path
return path, nil
}
}
return "", &temporaryPathError{dir}
}

View file

@ -1,13 +1,11 @@
package mmv
import (
"io/ioutil"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRename(t *testing.T) {
@ -17,7 +15,7 @@ func TestRename(t *testing.T) {
contents map[string]string
expected map[string]string
count int
err error
err string
}{
{
name: "nothing",
@ -40,7 +38,7 @@ func TestRename(t *testing.T) {
name: "two files",
files: map[string]string{
"foo": "qux",
"bar": "quxx",
"bar": "quux",
},
count: 2,
contents: map[string]string{
@ -50,7 +48,7 @@ func TestRename(t *testing.T) {
},
expected: map[string]string{
"qux": "0",
"quxx": "1",
"quux": "1",
"baz": "2",
},
},
@ -142,7 +140,11 @@ func TestRename(t *testing.T) {
"foo": "0",
"bar": "1",
},
err: &emptyPathError{},
expected: map[string]string{
"foo": "0",
"bar": "1",
},
err: "empty path error",
},
{
name: "empty destination path error",
@ -154,7 +156,11 @@ func TestRename(t *testing.T) {
"foo": "0",
"bar": "1",
},
err: &emptyPathError{},
expected: map[string]string{
"foo": "0",
"bar": "1",
},
err: "empty path error",
},
{
name: "same destination error",
@ -168,7 +174,12 @@ func TestRename(t *testing.T) {
"bar": "1",
"baz": "2",
},
err: &sameDestinationError{"baz"},
expected: map[string]string{
"foo": "0",
"bar": "1",
"baz": "2",
},
err: "duplicate destination: baz",
},
{
name: "clean source path",
@ -196,7 +207,11 @@ func TestRename(t *testing.T) {
"foo": "0",
"bar": "1",
},
err: &sameSourceError{"foo"},
expected: map[string]string{
"foo": "0",
"bar": "1",
},
err: "duplicate source: foo",
},
{
name: "cleaned path same destination error",
@ -208,7 +223,11 @@ func TestRename(t *testing.T) {
"foo": "0",
"bar": "1",
},
err: &sameDestinationError{"baz"},
expected: map[string]string{
"foo": "0",
"bar": "1",
},
err: "duplicate destination: baz",
},
{
name: "same source and destination",
@ -231,7 +250,31 @@ func TestRename(t *testing.T) {
"foo/": "foo/",
"bar/": "foo",
},
err: &sameDestinationError{"foo"},
err: "duplicate destination: foo",
},
{
name: "undo on error",
files: map[string]string{
"foo": "bar",
"bar": "foo",
"baz": "qux",
"qux": "quux",
"quux": "baz",
},
count: 7,
contents: map[string]string{
"foo": "0",
"bar": "1",
"baz": "2",
"qux": "3",
},
expected: map[string]string{
"foo": "0",
"bar": "1",
"baz": "2",
"qux": "3",
},
err: "quux: ", // no such file or directory
},
{
name: "create destination directory",
@ -252,22 +295,120 @@ func TestRename(t *testing.T) {
"a/b/c/baz": "2",
},
},
{
name: "invalid rename error",
files: map[string]string{
"x/y": "x",
},
contents: map[string]string{
"x/y": "0",
},
expected: map[string]string{
"x/y": "0",
},
err: "invalid rename: x",
},
{
name: "invalid rename error",
files: map[string]string{
"x/y": "x/y/z",
},
contents: map[string]string{
"x/y": "0",
},
expected: map[string]string{
"x/y": "0",
},
err: "invalid rename: x",
},
{
name: "directory renames",
files: map[string]string{
"x/foo": "y/bar",
"x/bar": "z/baz",
"x/qux": "z/qux",
"x/quy": "z/baz/qux",
"x/": "z/",
"y/bar": "x/foo",
"y/qux": "x/qux",
"y/": "w/",
"w/": "y/",
"w/x/": "y/y/",
"w/x/x/": "x/z/",
"w/x/y": "y/x/y",
"w/x/z": "w/x/z",
"w/x/w": "x/x/w",
"v/": "v/",
"v/x": "v/y",
"v/x/x": "v/y/x",
"xxxxx": "yyyyy",
},
count: 26,
contents: map[string]string{
"x/foo": "0",
"x/bar/a": "1",
"x/qux": "2",
"x/quy": "3",
"x/quz": "4",
"y/bar": "5",
"y/baz": "6",
"y/qux": "7",
"w/a": "8",
"w/x/a": "9",
"w/x/x/a": "10",
"w/x/y": "11",
"w/x/z": "12",
"w/x/w": "13",
"v/x/x": "14",
"xxxxx": "15",
},
expected: map[string]string{
"y/bar": "0",
"z/baz/a": "1",
"z/qux": "2",
"z/baz/qux": "3",
"z/quz": "4",
"x/foo": "5",
"w/baz": "6",
"x/qux": "7",
"y/a": "8",
"y/y/a": "9",
"x/z/a": "10",
"y/x/y": "11",
"w/x/z": "12",
"x/x/w": "13",
"v/y/x": "14",
"yyyyy": "15",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dir, err := ioutil.TempDir("", "mmv-")
defer os.RemoveAll(dir)
require.NoError(t, os.Chdir(dir))
require.NoError(t, err)
require.NoError(t, setupFiles(tc.contents))
dir, err := os.MkdirTemp("", "mmv-")
if err != nil {
t.Fatalf("os.MkdirTemp returned an error: %s", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
if err := os.Chdir(dir); err != nil {
t.Fatalf("os.Chdir returned an error: %s", err)
}
if err := setupFiles(tc.contents); err != nil {
t.Fatalf("setupFiles returned an error: %s", err)
}
rs, _ := buildRenames(clone(tc.files))
assert.Equal(t, tc.count, len(rs))
got := Rename(tc.files)
if tc.err == nil {
require.NoError(t, got)
assert.Equal(t, tc.expected, fileContents("."))
} else {
assert.Equal(t, tc.err, got)
if got := len(rs); got != tc.count {
t.Errorf("expected: %d, got: %d", tc.count, got)
}
err = Rename(tc.files)
if tc.err == "" {
if err != nil {
t.Errorf("Rename returned an error: %s", err)
}
} else if !strings.Contains(err.Error(), tc.err) {
t.Errorf("error should contain: %s, got: %s", tc.err, err)
}
if got := fileContents("."); !reflect.DeepEqual(got, tc.expected) {
t.Errorf("expected: %v, got: %v", tc.expected, got)
}
})
}
@ -275,7 +416,13 @@ func TestRename(t *testing.T) {
func setupFiles(contents map[string]string) error {
for f, cnt := range contents {
if err := ioutil.WriteFile(f, []byte(cnt), 0600); err != nil {
dir := filepath.Dir(f)
if dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
}
if err := os.WriteFile(f, []byte(cnt), 0o600); err != nil {
return err
}
}
@ -284,7 +431,7 @@ func setupFiles(contents map[string]string) error {
func fileContents(dir string) map[string]string {
m := make(map[string]string)
fis, _ := ioutil.ReadDir(dir)
fis, _ := os.ReadDir(dir)
for _, fi := range fis {
if fi.IsDir() {
for k, v := range fileContents(filepath.Join(dir, fi.Name())) {
@ -292,7 +439,7 @@ func fileContents(dir string) map[string]string {
}
} else {
path := filepath.Join(dir, fi.Name())
cnt, _ := ioutil.ReadFile(path)
cnt, _ := os.ReadFile(path)
m[filepath.ToSlash(path)] = string(cnt)
}
}