├── .gitignore ├── go.mod ├── .github └── workflows │ └── release.yaml ├── LICENSE ├── go.sum ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | rmbin 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/malisetti/rmbin 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b 7 | github.com/spf13/cobra v1.6.1 8 | ) 9 | 10 | require ( 11 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 12 | github.com/spf13/pflag v1.0.5 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yaml 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release-linux-amd64: 9 | name: release linux/amd64 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 14 | goos: [linux, windows, darwin] 15 | goarch: ["386", amd64, arm64] 16 | exclude: 17 | - goarch: "386" 18 | goos: darwin 19 | - goarch: arm64 20 | goos: windows 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: wangyoucao577/go-release-action@v1 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | goos: ${{ matrix.goos }} 27 | goarch: ${{ matrix.goarch }} 28 | binary_name: rmbin 29 | extra_files: LICENSE README.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Seshachalam Malisetti 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 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 3 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 | github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8= 5 | github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks= 6 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 7 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 8 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 9 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 10 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### rmbin 2 | rmbin is a command-line tool that provides a local recycle bin for your files. When you delete a file with rmbin, it is moved to a trash folder instead of being deleted permanently. You can later restore or permanently delete the file from the trash folder as needed. 3 | 4 | ## Installation 5 | This requires `go` 1.17+. 6 | 7 | ### Go 8 | To install using `go`: 9 | ```bash 10 | go install github.com/malisetti/rmbin@latest 11 | ``` 12 | 13 | ### Build 14 | To build a binary for the local machine architecture: 15 | ```bash 16 | git clone https://github.com/malisetti/rmbin.git 17 | cd rmbin 18 | go build 19 | ``` 20 | 21 | [releases]: https://github.com/malisetti/rmbin/releases 22 | 23 | ## Usage 24 | To use rmbin, you can run the following commands: 25 | 26 | - `rmbin delete [files...]`: Moves one or more files to the recycle bin. You can specify one or more file paths as arguments. 27 | - `rmbin restore [files...]`: Restores one or more files from the recycle bin. You can specify one or more file paths as arguments. 28 | - `rmbin gc [days]`: Permanently deletes files that have been in the recycle bin for the specified number of days. If no number of days is specified, files will be deleted that are over 30 days old. 29 | - `rmbin list`: Lists the files in the recycle bin. 30 | - `rmbin help`: Shows help information for the program. 31 | 32 | ## Configuration 33 | rmbin stores its trash folder and trash map in the user's home directory by default. You can not configure these values now. 34 | 35 | ## License 36 | This program is licensed under the MIT License. See the LICENSE file for more information. 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/juju/fslock" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type RecycleBin struct { 17 | trashPath string 18 | trashMap map[string]string 19 | } 20 | 21 | func NewRecycleBin(trashPath string, trashMap map[string]string) *RecycleBin { 22 | return &RecycleBin{trashPath, trashMap} 23 | } 24 | 25 | func (rb *RecycleBin) Delete2(path string) error { 26 | absPath, err := filepath.Abs(path) 27 | if err != nil { 28 | return err 29 | } 30 | fileInfo, err := os.Stat(absPath) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | fileName := fileInfo.Name() 36 | fileExt := filepath.Ext(fileName) 37 | fileBase := strings.TrimSuffix(fileName, fileExt) 38 | trashFileName := fmt.Sprintf("%s_%d%s", fileBase, time.Now().Unix(), fileExt) 39 | 40 | trashPath := filepath.Join(rb.trashPath, trashFileName) 41 | err = os.Rename(absPath, trashPath) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | rb.trashMap[absPath] = trashPath 47 | 48 | fmt.Printf("Deleted %s, moved to %s\n", path, trashPath) 49 | return nil 50 | } 51 | 52 | func (rb *RecycleBin) Delete(path string) error { 53 | absPath, err := filepath.Abs(path) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if !force { 59 | var confirmation string 60 | fmt.Printf("rm: remove regular file '%s'? ", absPath) 61 | fmt.Scanln(&confirmation) 62 | if confirmation != "y" && confirmation != "yes" { 63 | return nil 64 | } 65 | } 66 | 67 | fileInfo, err := os.Stat(absPath) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | fileName := fileInfo.Name() 73 | fileExt := filepath.Ext(fileName) 74 | fileBase := strings.TrimSuffix(fileName, fileExt) 75 | trashFileName := fmt.Sprintf("%s_%d%s", fileBase, time.Now().Unix(), fileExt) 76 | 77 | trashPath := filepath.Join(rb.trashPath, trashFileName) 78 | err = os.Rename(absPath, trashPath) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | rb.trashMap[absPath] = trashPath 84 | 85 | fmt.Printf("Deleted %s, moved to %s\n", path, trashPath) 86 | return nil 87 | } 88 | 89 | func (rb *RecycleBin) Restore(path string) error { 90 | absPath, err := filepath.Abs(path) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | trashFilePath, ok := rb.trashMap[absPath] 96 | if !ok { 97 | return nil 98 | } 99 | 100 | err = os.Rename(trashFilePath, absPath) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | delete(rb.trashMap, absPath) 106 | 107 | fmt.Printf("Restored %s\n", absPath) 108 | return nil 109 | } 110 | 111 | func (rb *RecycleBin) GarbageCollect(days int) error { 112 | cutoff := time.Now().AddDate(0, 0, -days).Unix() 113 | err := filepath.Walk(rb.trashPath, func(path string, info os.FileInfo, err error) error { 114 | if err != nil { 115 | return err 116 | } 117 | if info.ModTime().Unix() < cutoff { 118 | err = os.RemoveAll(path) 119 | if err != nil { 120 | return err 121 | } 122 | originalPath := rb.GetOriginalPath(path) 123 | if originalPath != "" { 124 | delete(rb.trashMap, originalPath) 125 | fmt.Printf("Removed %s\n", path) 126 | } 127 | } 128 | return nil 129 | }) 130 | return err 131 | } 132 | 133 | func (rb *RecycleBin) List() error { 134 | for k := range rb.trashMap { 135 | fmt.Println(k) 136 | } 137 | return nil 138 | } 139 | 140 | func (rb *RecycleBin) SaveTrashMap(p string) error { 141 | f, err := os.Create(p) 142 | if err != nil { 143 | return err 144 | } 145 | defer f.Close() 146 | encoder := json.NewEncoder(f) 147 | err = encoder.Encode(rb.trashMap) 148 | if err != nil { 149 | return err 150 | } 151 | return nil 152 | } 153 | 154 | func (rb *RecycleBin) GetOriginalPath(trashFile string) string { 155 | for k, v := range rb.trashMap { 156 | if v == trashFile { 157 | return k 158 | 159 | } 160 | } 161 | return "" 162 | } 163 | 164 | func loadTrashMap(trashMapPath string) (map[string]string, error) { 165 | file, err := os.Open(trashMapPath) 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer file.Close() 170 | 171 | trashMap := make(map[string]string) 172 | decoder := json.NewDecoder(file) 173 | _ = decoder.Decode(&trashMap) 174 | 175 | return trashMap, nil 176 | } 177 | 178 | func initTrashMap(trashMapPath string) error { 179 | _, err := os.Stat(trashMapPath) 180 | if os.IsNotExist(err) { 181 | // Create the directory for the trash map file if it doesn't exist 182 | err = os.MkdirAll(filepath.Dir(trashMapPath), 0755) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | // Create an empty trash map file 188 | file, err := os.Create(trashMapPath) 189 | if err != nil { 190 | return err 191 | } 192 | defer file.Close() 193 | return nil 194 | } 195 | 196 | return err 197 | } 198 | 199 | var recursive bool 200 | var force bool 201 | 202 | func main() { 203 | homeDir, err := os.UserHomeDir() 204 | if err != nil { 205 | fmt.Println("Failed to get user home directory:", err) 206 | os.Exit(1) 207 | } 208 | trashPath := filepath.Join(homeDir, ".trash") 209 | trashMapPath := filepath.Join(trashPath, ".trashmap.json") 210 | err = initTrashMap(trashMapPath) 211 | if err != nil { 212 | fmt.Println(err) 213 | os.Exit(1) 214 | } 215 | lock := fslock.New(trashMapPath) 216 | lockErr := lock.TryLock() 217 | if lockErr != nil { 218 | fmt.Println("falied to acquire lock > " + lockErr.Error()) 219 | return 220 | } 221 | defer lock.Unlock() 222 | trashMap, err := loadTrashMap(trashMapPath) 223 | if err != nil { 224 | fmt.Println("failed to load trashMap:", err) 225 | os.Exit(1) 226 | } 227 | rb := NewRecycleBin(trashPath, trashMap) 228 | 229 | var rootCmd = &cobra.Command{ 230 | Use: "rmbin", 231 | Version: "v0.0.2", 232 | } 233 | 234 | var deleteCmd = &cobra.Command{ 235 | Use: "delete [files...]", 236 | Aliases: []string{"d"}, 237 | Short: "Move files to recycle bin", 238 | Args: cobra.MinimumNArgs(1), 239 | RunE: func(cmd *cobra.Command, args []string) error { 240 | for _, arg := range args { 241 | fileInfo, err := os.Stat(arg) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | if fileInfo.IsDir() { 247 | if recursive { 248 | err := filepath.Walk(arg, func(path string, info os.FileInfo, err error) error { 249 | if err != nil { 250 | return err 251 | } 252 | if info.IsDir() { 253 | return nil 254 | } 255 | return rb.Delete(path) 256 | }) 257 | if err != nil { 258 | return err 259 | } 260 | } else { 261 | fmt.Printf("Cannot remove '%s': Is a directory\n", arg) 262 | continue 263 | } 264 | } else { 265 | err = rb.Delete(arg) 266 | if err != nil { 267 | return err 268 | } 269 | } 270 | } 271 | return rb.SaveTrashMap(trashMapPath) 272 | }, 273 | } 274 | 275 | deleteCmd.Flags().BoolVarP(&recursive, "recursive", "r", false, "remove directories and their contents recursively") 276 | deleteCmd.Flags().BoolVarP(&force, "force", "f", false, "ignore nonexistent files and arguments, never prompt") 277 | 278 | var restoreCmd = &cobra.Command{ 279 | Use: "restore [files...]", 280 | Aliases: []string{"r"}, 281 | Short: "Restore files from recycle bin", 282 | Args: cobra.MinimumNArgs(1), 283 | RunE: func(cmd *cobra.Command, args []string) error { 284 | for _, arg := range args { 285 | err := rb.Restore(arg) 286 | if err != nil { 287 | fmt.Println(err) 288 | } 289 | } 290 | return rb.SaveTrashMap(trashMapPath) 291 | }, 292 | } 293 | 294 | var garbageCollectCmd = &cobra.Command{ 295 | Use: "gc [days]", 296 | Short: "Clean up the recycle bin", 297 | Args: cobra.MaximumNArgs(1), 298 | RunE: func(cmd *cobra.Command, args []string) error { 299 | days := 30 300 | if len(args) == 1 { 301 | var err error 302 | days, err = strconv.Atoi(args[0]) 303 | if err != nil { 304 | return err 305 | } 306 | } 307 | 308 | err := rb.GarbageCollect(days) 309 | if err != nil { 310 | fmt.Println(err) 311 | } 312 | return rb.SaveTrashMap(trashMapPath) 313 | }, 314 | } 315 | 316 | var listCmd = &cobra.Command{ 317 | Use: "list", 318 | Aliases: []string{"ls"}, 319 | Short: "Lists the recycle bin files", 320 | RunE: func(cmd *cobra.Command, args []string) error { 321 | err := rb.List() 322 | if err != nil { 323 | fmt.Println(err) 324 | } 325 | return nil 326 | }, 327 | } 328 | 329 | rootCmd.AddCommand(listCmd) 330 | rootCmd.AddCommand(deleteCmd) 331 | rootCmd.AddCommand(restoreCmd) 332 | rootCmd.AddCommand(garbageCollectCmd) 333 | 334 | err = rootCmd.Execute() 335 | if err != nil { 336 | fmt.Println(err) 337 | os.Exit(1) 338 | } 339 | } 340 | --------------------------------------------------------------------------------