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
This commit is contained in:
itchyny 2021-01-07 21:37:36 +09:00
parent 9e2bfa58df
commit 4ce13f9e9e
2 changed files with 200 additions and 44 deletions

152
mmv.go
View file

@ -5,6 +5,7 @@ import (
"math/rand"
"os"
"path/filepath"
"strings"
)
// Rename multiple files.
@ -74,6 +75,14 @@ func (err *sameDestinationError) Error() string {
return fmt.Sprintf("duplicate destination: %s", err.path)
}
type invalidRenameError struct {
src, dst string
}
func (err *invalidRenameError) Error() string {
return fmt.Sprintf("invalid rename: %s, %s", err.src, err.dst)
}
func buildRenames(files map[string]string) ([]rename, error) {
revs := make(map[string]string, len(files)) // reverse of files
@ -104,66 +113,121 @@ 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 directories 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 := randomPath(filepath.Dir(s))
rs = append(rs, rename{src, tmp})
files[tmp] = files[dst]
delete(files, src)
revs[dst] = tmp
}
continue L
}
}
}
}
// remove source == destination
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]--
}
// 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
// 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
}
dst = src
} else {
break
}
}
// if there is a cycle, rename the temporary file
if cycle {
rs = append(rs, rename{tmp, dst})
// if there is a cycle, rename the temporary file
if cycle {
rs = append(rs, rename{tmp, dst})
}
}
}
return rs, nil

View file

@ -296,6 +296,92 @@ 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) {
@ -331,6 +417,12 @@ func TestRename(t *testing.T) {
func setupFiles(contents map[string]string) error {
for f, cnt := range contents {
dir := filepath.Dir(f)
if dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
}
if err := ioutil.WriteFile(f, []byte(cnt), 0o600); err != nil {
return err
}