mirror of
https://github.com/itchyny/mmv.git
synced 2025-12-26 22:24:58 +08:00
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:
parent
9e2bfa58df
commit
4ce13f9e9e
2 changed files with 200 additions and 44 deletions
152
mmv.go
152
mmv.go
|
|
@ -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
|
||||
|
|
|
|||
92
mmv_test.go
92
mmv_test.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue