├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── mcp │ ├── main.go │ └── main_test.go └── go.mod /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Setup Go 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: 1.x 19 | - name: Add $GOPATH/bin to $PATH 20 | run: echo "::add-path::$(go env GOPATH)/bin" 21 | - name: Test 22 | run: make test 23 | - name: Lint 24 | run: make lint 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Setup Go 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: 1.x 19 | - name: Add $GOPATH/bin to $PATH 20 | run: echo "::add-path::$(go env GOPATH)/bin" 21 | - name: Cross build 22 | run: make cross 23 | - name: Create Release 24 | id: create_release 25 | uses: actions/create-release@master 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | with: 29 | tag_name: ${{ github.ref }} 30 | release_name: Release ${{ github.ref }} 31 | - name: Upload 32 | run: make upload 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 skanehira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := mcp 2 | VERSION := $$(make -s show-version) 3 | VERSION_PATH := cmd/$(BIN) 4 | CURRENT_REVISION := $(shell git rev-parse --short HEAD) 5 | BUILD_LDFLAGS := "-s -w -X main.revision=$(CURRENT_REVISION)" 6 | GOBIN ?= $(shell go env GOPATH)/bin 7 | export GO111MODULE=on 8 | 9 | .PHONY: all 10 | all: clean build 11 | 12 | .PHONY: build 13 | build: 14 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/$(BIN) 15 | 16 | .PHONY: install 17 | install: 18 | go install -ldflags=$(BUILD_LDFLAGS) ./... 19 | 20 | .PHONY: show-version 21 | show-version: $(GOBIN)/gobump 22 | @gobump show -r $(VERSION_PATH) 23 | 24 | $(GOBIN)/gobump: 25 | @cd && go get github.com/x-motemen/gobump/cmd/gobump 26 | 27 | .PHONY: cross 28 | cross: $(GOBIN)/goxz 29 | goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) 30 | 31 | $(GOBIN)/goxz: 32 | cd && go get github.com/Songmu/goxz/cmd/goxz 33 | 34 | .PHONY: test 35 | test: build 36 | go test -v ./... 37 | 38 | .PHONY: lint 39 | lint: $(GOBIN)/golint 40 | go vet ./... 41 | golint -set_exit_status ./... 42 | 43 | $(GOBIN)/golint: 44 | cd && go get golang.org/x/lint/golint 45 | 46 | .PHONY: clean 47 | clean: 48 | rm -rf $(BIN) goxz 49 | go clean 50 | 51 | .PHONY: bump 52 | bump: $(GOBIN)/gobump 53 | ifneq ($(shell git status --porcelain),) 54 | $(error git workspace is dirty) 55 | endif 56 | ifneq ($(shell git rev-parse --abbrev-ref HEAD),master) 57 | $(error current branch is not master) 58 | endif 59 | @gobump up -w "$(VERSION_PATH)" 60 | git commit -am "bump up version to $(VERSION)" 61 | git tag "v$(VERSION)" 62 | git push origin master 63 | git push origin "refs/tags/v$(VERSION)" 64 | 65 | .PHONY: upload 66 | upload: $(GOBIN)/ghr 67 | ghr "v$(VERSION)" goxz 68 | 69 | $(GOBIN)/ghr: 70 | cd && go get github.com/tcnksm/ghr 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcp 2 | Copy multiple files using your `$EDITOR`. 3 | This is inspired by [mmv](http://github.com/itchyny/mmv) 4 | 5 | ![](https://i.imgur.com/2D9S6WW.gif) 6 | 7 | ## Usage 8 | ```sh 9 | # copy all of current directory's files 10 | $ mcp * 11 | 12 | # copy file 13 | $ mcp file 14 | ``` 15 | 16 | ## Installtion 17 | ```sh 18 | $ go get github.com/skanehira/mcp/cmd/mcp 19 | ``` 20 | 21 | ## Author 22 | skanehira 23 | -------------------------------------------------------------------------------- /cmd/mcp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | exitCodeOK = iota 16 | exitCodeErr 17 | ) 18 | 19 | var version = "1.0.0" 20 | 21 | var ( 22 | stdout io.Writer = os.Stdout 23 | ) 24 | 25 | func main() { 26 | name := "mcp" 27 | fs := flag.NewFlagSet(name, flag.ContinueOnError) 28 | fs.SetOutput(os.Stderr) 29 | fs.Usage = func() { 30 | fs.SetOutput(os.Stdout) 31 | fmt.Printf(`%[1]s - copy multiple files with editor 32 | 33 | Version: %s 34 | 35 | Usage: 36 | $ %[1]s file ... 37 | `, name, version) 38 | } 39 | 40 | if err := fs.Parse(os.Args[1:]); err != nil { 41 | if err == flag.ErrHelp { 42 | return 43 | } 44 | os.Exit(exitCodeErr) 45 | } 46 | 47 | args := fs.Args() 48 | if len(args) == 0 { 49 | fs.Usage() 50 | return 51 | } 52 | 53 | if err := run(args); err != nil { 54 | fmt.Fprintln(os.Stderr, err) 55 | os.Exit(exitCodeErr) 56 | } 57 | } 58 | 59 | func run(args []string) error { 60 | for _, arg := range args { 61 | if _, err := os.Lstat(arg); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | existed := make(map[string]bool, len(args)) 67 | for _, arg := range args { 68 | if existed[arg] { 69 | return fmt.Errorf("duplicat source %s", arg) 70 | } 71 | existed[arg] = true 72 | } 73 | 74 | f, err := ioutil.TempFile("", "mcp-") 75 | if err != nil { 76 | return err 77 | } 78 | defer os.Remove(f.Name()) 79 | 80 | for _, arg := range args { 81 | f.WriteString(arg + "\n") 82 | } 83 | f.Close() 84 | 85 | editor := os.Getenv("EDITOR") 86 | if editor == "" { 87 | editor = "vi" 88 | } 89 | 90 | cmd := exec.Command(editor, f.Name()) 91 | cmd.Stdout = os.Stdout 92 | cmd.Stdin = os.Stdin 93 | cmd.Stderr = os.Stderr 94 | 95 | if err := cmd.Run(); err != nil { 96 | return fmt.Errorf("abort copy files") 97 | } 98 | 99 | b, err := ioutil.ReadFile(f.Name()) 100 | if err != nil { 101 | return err 102 | } 103 | if len(b) == 0 { 104 | return fmt.Errorf("no destination files") 105 | } 106 | 107 | dests := strings.Split(strings.TrimRight(string(b), "\n"), "\n") 108 | destLen := len(dests) 109 | 110 | argsLen := len(args) 111 | var sources []string 112 | 113 | if argsLen > destLen { 114 | sources = args[:destLen] 115 | } else { 116 | sources = args 117 | } 118 | 119 | if err := mcp(sources, dests); err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | } 125 | 126 | func mcp(sources, dests []string) error { 127 | for i, s := range sources { 128 | d := dests[i] 129 | if d == "" || s == d { 130 | continue 131 | } 132 | 133 | info, err := os.Lstat(s) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | if err := copy(s, dests[i], info); err != nil { 139 | return err 140 | } 141 | } 142 | 143 | return nil 144 | } 145 | 146 | func copy(src, dest string, info os.FileInfo) error { 147 | if info.Mode()&os.ModeSymlink == os.ModeSymlink { 148 | return link(src, dest) 149 | } 150 | if info.IsDir() { 151 | return dcopy(src, dest, info) 152 | } 153 | return fcopy(src, dest, info) 154 | } 155 | 156 | func dcopy(srcDir, destDir string, info os.FileInfo) error { 157 | if srcDir == filepath.Dir(destDir) { 158 | return fmt.Errorf("%s and %s is same parent directory", srcDir, destDir) 159 | } 160 | if err := os.MkdirAll(destDir, 0775); err != nil { 161 | return err 162 | } 163 | defer os.Chmod(destDir, info.Mode()) 164 | 165 | files, err := ioutil.ReadDir(srcDir) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | for _, f := range files { 171 | sd := filepath.Join(srcDir, f.Name()) 172 | dd := filepath.Join(destDir, f.Name()) 173 | 174 | if err := copy(sd, dd, f); err != nil { 175 | return err 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | func fcopy(src, dest string, info os.FileInfo) error { 182 | if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil { 183 | return err 184 | } 185 | 186 | out, err := os.Create(dest) 187 | if err != nil { 188 | return err 189 | } 190 | defer out.Close() 191 | 192 | if err := os.Chmod(out.Name(), info.Mode()); err != nil { 193 | return err 194 | } 195 | 196 | s, err := os.Open(src) 197 | if err != nil { 198 | return err 199 | } 200 | defer s.Close() 201 | 202 | fmt.Fprintf(stdout, "copy %s to %s\n", src, dest) 203 | if _, err := io.Copy(out, s); err != nil { 204 | return err 205 | } 206 | return nil 207 | } 208 | 209 | func link(src, dest string) error { 210 | if src == filepath.Dir(dest) { 211 | return fmt.Errorf("%s and %s is same parent directory", src, dest) 212 | } 213 | if err := os.MkdirAll(filepath.Dir(dest), 0775); err != nil { 214 | return err 215 | } 216 | 217 | fmt.Fprintf(stdout, "copy %s to %s\n", src, dest) 218 | src, err := os.Readlink(src) 219 | if err != nil { 220 | return err 221 | } 222 | return os.Symlink(src, dest) 223 | } 224 | -------------------------------------------------------------------------------- /cmd/mcp/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func compareDiff(t *testing.T, source, dest string) { 11 | t.Helper() 12 | 13 | _, err := os.Lstat(dest) 14 | if err != nil { 15 | t.Fatalf("invalid file %s: %s", dest, err) 16 | } 17 | 18 | s, err := ioutil.ReadFile(source) 19 | if err != nil { 20 | t.Fatalf("failed to read source file %s: %s", source, err) 21 | } 22 | 23 | d, err := ioutil.ReadFile(dest) 24 | if err != nil { 25 | t.Fatalf("failed to read dest file %s: %s", dest, err) 26 | } 27 | 28 | if string(s) != string(d) { 29 | t.Fatalf("has diff\n src:%s\n dest:%s\n", string(s), string(d)) 30 | } 31 | } 32 | 33 | func createFile(t *testing.T, name string) { 34 | t.Helper() 35 | 36 | dir := filepath.Dir(name) 37 | if _, err := os.Lstat(dir); os.IsNotExist(err) { 38 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 39 | t.Fatalf("failed to create directory %s: %s", dir, err) 40 | } 41 | } 42 | 43 | f, err := os.Create(name) 44 | if err != nil { 45 | t.Fatalf("failed to create source file %s: %s", name, err) 46 | } 47 | f.WriteString("1234") 48 | f.Close() 49 | } 50 | 51 | func flatten(t *testing.T, f string) []string { 52 | t.Helper() 53 | 54 | info, err := os.Stat(f) 55 | if err != nil { 56 | t.Fatalf("failed to get info: %s", err) 57 | } 58 | if !info.IsDir() { 59 | return []string{f} 60 | } 61 | 62 | var files []string 63 | contents, err := ioutil.ReadDir(f) 64 | if err != nil { 65 | t.Fatalf("failed to get directory contents: %s", err) 66 | } 67 | for _, c := range contents { 68 | if c.IsDir() { 69 | files = append(files, flatten(t, filepath.Join(f, c.Name()))...) 70 | } else { 71 | files = append(files, filepath.Join(f, c.Name())) 72 | } 73 | } 74 | return files 75 | } 76 | 77 | func symlink(t *testing.T, src, dest string) { 78 | t.Helper() 79 | 80 | s, err := os.Readlink(src) 81 | if err != nil { 82 | t.Fatalf("failed get symblink: %s", err) 83 | } 84 | 85 | err = os.Symlink(s, dest) 86 | if err != nil { 87 | t.Fatalf("failed set symblink: %s", err) 88 | } 89 | } 90 | 91 | func TestMcpSuccess(t *testing.T) { 92 | stdout = ioutil.Discard 93 | 94 | dir, err := ioutil.TempDir("", "") 95 | if err != nil { 96 | t.Fatalf("failed to create temp dir: %s", err) 97 | } 98 | defer os.RemoveAll(dir) 99 | 100 | for _, f := range []string{ 101 | filepath.Join(dir, "file"), 102 | filepath.Join(dir, "sub", "file"), 103 | } { 104 | createFile(t, f) 105 | } 106 | 107 | err = os.Symlink(filepath.Join(dir, "file"), filepath.Join(dir, "symlink")) 108 | if err != nil { 109 | t.Fatalf("failed set symblink: %s", err) 110 | } 111 | 112 | tests := []struct { 113 | sources []string 114 | dests []string 115 | }{ 116 | { 117 | sources: []string{ 118 | filepath.Join(dir, "file"), 119 | filepath.Join(dir, "sub"), 120 | filepath.Join(dir, "symlink"), 121 | }, 122 | dests: []string{ 123 | filepath.Join(dir, "dest", "file"), 124 | filepath.Join(dir, "dest", "sub"), 125 | filepath.Join(dir, "dest_symlink"), 126 | }, 127 | }, 128 | } 129 | 130 | for _, tt := range tests { 131 | if err := mcp(tt.sources, tt.dests); err != nil { 132 | t.Fatalf("failed to copy files: %s", err) 133 | } 134 | 135 | var sources, dests []string 136 | 137 | for _, s := range tt.sources { 138 | sources = append(sources, flatten(t, s)...) 139 | } 140 | 141 | for _, d := range tt.dests { 142 | dests = append(dests, flatten(t, d)...) 143 | } 144 | 145 | for i, s := range sources { 146 | compareDiff(t, s, dests[i]) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/skanehira/mcp 2 | 3 | go 1.14 4 | --------------------------------------------------------------------------------