// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"os"
"path/filepath"
)
// FileDelAll recursevely removes file `name` from the `root`
// directory. It is useful to get truly empty directories after
// cloning checked out almost empty directories containing
// a stake file like `.gitkeep`
func FileDelAll(root, name string) error {
return filepath.Walk(root, func(p string, i os.FileInfo, err error) error {
if err != nil {
return (err)
}
if filepath.Base(p) == name {
if os.Remove(p) != nil {
return (err)
}
}
return nil
})
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"os"
)
// FileInfoPath is a wrapper of os.FileInfo with an additional
// field to store the path to the file of interest
type FileInfoPath struct {
os.FileInfo
path string
}
// NewFileInfoPath creates new FileInfoPath struct
func NewFileInfoPath(path string) (*FileInfoPath, error) {
fi, err := os.Stat(path)
if err != nil {
return &FileInfoPath{nil, path}, err
}
return &FileInfoPath{fi, path}, nil
}
// MakeFipSlice creates a slice of *FileInfoPaths based on
// provided list of file names. It returns the first
// encountered error.
func MakeFipSlice(files ...string) ([]*FileInfoPath, error) {
fips := make([]*FileInfoPath, 0)
for _, name := range files {
fip, err := NewFileInfoPath(name)
if err != nil {
return nil, err
}
fips = append(fips, fip)
}
return fips, nil
}
// Path returns the stored full path in the FileInfoPath struct
func (fip *FileInfoPath) Path() string {
return fip.path
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"bufio"
"os"
"testing"
"time"
)
// FileRank is the signature of functions which are
// provided to TreeDiff to compare two *FileInfoPath structs
// and related files.
//
// When comparing filesystem objects, the algorithm used in
// the TreeDiff function expects the "less-than" logic from
// comparator functions. It means, that a comparator is expected
// to return "true" if and only if the "left" parameter is
// strictly less than the "right" parameter according to the
// comparator's criteria.
//
// FileRank does not provide an error handling interface. If
// needed, it can be implemented via a closure. See the
// ByContent comparator generator for an example of it.
type FileRank func(left, right *FileInfoPath) bool
// ByName is basic for comparing directories and should
// be provided as a first comparator in most cases
func ByName(left, right *FileInfoPath) bool {
return left.Name() < right.Name()
}
// ByDir differentiates directorries vs. files and puts
// directories earlier in a sort order
func ByDir(left, right *FileInfoPath) bool {
return left.IsDir() && !right.IsDir()
}
// BySize compares sizes of files, given that both of the
// files are regular files as opposed to not directories, etc.
func BySize(left, right *FileInfoPath) bool {
return left.Mode().IsRegular() &&
right.Mode().IsRegular() &&
(left.Size() < right.Size())
}
// ByTime compares files' last modification times with up to
// 10µs precision to accommodate filesyustem quirks
func ByTime(left, right *FileInfoPath) bool {
return left.ModTime().Before(right.ModTime().Add(-10 * time.Microsecond))
}
// ByPerm compares bits 0-8 of Unix-like file permissions
func ByPerm(left, right *FileInfoPath) bool {
return left.Mode().Perm() < right.Mode().Perm()
}
// ByContent returns a function which compares files'
// content without first comparing sizes. For example,
// file containing "aaa" will rank as lesser than the one
// containing "ab" even though it is opposite to their sizes.
// To consider sizes first, make sure to specify the BySize
// comparator earlier in the chain.
func ByContent(t *testing.T) FileRank {
return func(left, right *FileInfoPath) bool {
leftF, err := os.Open(left.Path())
if err != nil {
t.Fatal(err)
}
defer leftF.Close()
rightF, err := os.Open(right.Path())
if err != nil {
t.Fatal(err)
}
defer rightF.Close()
leftBuf := bufio.NewReader(leftF)
rightBuf := bufio.NewReader(rightF)
for {
rByte, err := rightBuf.ReadByte()
if err != nil {
return false
}
lByte, err := leftBuf.ReadByte()
if err != nil {
return true
}
if lByte == rByte {
continue
}
if lByte < rByte {
return true
}
return false
}
}
}
// Less applies provided comparators to the pair of *FileInfoPath structs.
func Less(left, right *FileInfoPath, cmps ...FileRank) bool {
for _, less := range cmps {
if less(left, right) {
return true
}
}
return false
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
)
// TempInitDir function creates a directory for holding
// temporary files according to platform preferences and
// returns the directory name and a cleanup function.
//
// The returned values are:
//
// 1. a string containing the created temporary directory path
//
// 2. a cleanup function to change back to the old working
// directory and to delete the temporary directory
//
// 3. an error
//
// If there was an error while creating the temporary
// directory, then the returned directory name is empty,
// cleanup funcion is nil, and the temp folder is
// expected to be already removed.
func TempInitDir() (string, func(), error) {
root, err := ioutil.TempDir("", "")
if err != nil {
os.RemoveAll(root)
return "", nil, err
}
return root, func() {
dirs := make([]string, 0)
err := filepath.Walk(
root,
func(fn string, fi os.FileInfo, er error) error {
if fi.IsDir() {
err = os.Chmod(fn, 0700)
if err != nil {
return err
}
dirs = append(dirs, fn)
return nil
}
return os.Remove(fn)
})
if err != nil {
log.Fatalln(err)
}
for i := len(dirs) - 1; i >= 0; i-- {
err = os.RemoveAll(dirs[i])
if err != nil {
log.Fatalln(err)
}
}
}, nil
}
// TempInitChdir creates a temporary directory in the same
// fashion as TempInitDir. It also changes into the newly
// created temporary directory and adds returning back
// to the old working directory to the returned cleanup
// function. The returned values are:
//
// 1. a string containing the previous working directory
//
// 2. a cleanup function to change back to the old working
// directory and to delete the temporary directory
//
// 3. an error
func TempInitChdir() (string, func(), error) {
root, cleanup, err := TempInitDir()
if err != nil {
return "", nil, err
}
wd, err := os.Getwd()
if err != nil {
cleanup()
return "", nil, err
}
err = os.Chdir(root)
if err != nil {
cleanup()
return "", nil, err
}
return wd,
func() {
os.Chdir(wd)
cleanup()
},
nil
}
// TempCloneDir function creates a copy of an existing
// directory with it's content - regular files only - for
// holding temporary test files.
//
// The returned values are:
//
// 1. a string containing the created temporary directory path
//
// 2. a cleanup function to change back to the old working
// directory and to delete the temporary directory
//
// 3. an error
//
// If there was an error while cloning the temporary
// directory, then the returned directory name is empty,
// cleanup funcion is nil, and the temp folder is
// expected to be already removed.
//
// The clone attempts to maintain the basic original Unix
// permissions (9-bit only, from the rxwrwxrwx set).
// If, however, the user does not have read permission
// for a file, or read+execute permission for a directory,
// then the clone process will naturally fail.
func TempCloneDir(src string) (string, func(), error) {
root, cleanup, err := TempInitDir()
if err != nil {
return "", nil, err
}
err = TreeCopy(src, root)
if err != nil {
cleanup()
return "", nil, err
}
return root, cleanup, nil
}
// TempCloneChdir clones a temporary directory in the same
// fashion as TempCloneDir. It also changes into the newly
// cloned temporary directory and adds returning back
// to the old working directory to the returned cleanup
// function. The returned values are:
//
// 1. a string containing the previous working directory
//
// 2. a cleanup function to change back to the old working
// directory and to delete the temporary directory
//
// 3. an error
func TempCloneChdir(src string) (string, func(), error) {
root, cleanup, err := TempCloneDir(src)
if err != nil {
return "", nil, err
}
wd, err := os.Getwd()
if err != nil {
cleanup()
return "", nil, err
}
err = os.Chdir(root)
if err != nil {
cleanup()
return "", nil, err
}
return wd,
func() {
os.Chdir(wd)
cleanup()
},
nil
}
// TempCreateChdir is a combination of `TempInitChdir` and
// `TreeCreate` functions. It creates a termporary directory,
// changes into it, populates it fron the provided `config`
// as `TreeCreate` would, and returns the old directory name
// and the cleanup function.
func TempCreateChdir(config io.Reader) (string, func(), error) {
old, cleanup, err := TempInitChdir()
if err != nil {
return "", nil, err
}
err = TreeCreate(config)
if err != nil {
cleanup()
return "", nil, err
}
return old, cleanup, nil
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"io"
"os"
"path/filepath"
"time"
)
type dirEntry struct {
name string
perm os.FileMode
time time.Time
}
// TreeCopy duplicates redular files and directories from
// inside the source directory into an existing destination
// directory.
func TreeCopy(src, dst string) error {
srcClean := filepath.Clean(src)
srcLen := len(srcClean)
dirs := make([]*dirEntry, 0)
err := filepath.Walk(
srcClean,
func(fn string, fi os.FileInfo, er error) error {
if er != nil || len(fn) <= srcLen {
return er
}
dest := filepath.Join(dst, fn[srcLen:])
if fi.Mode().IsRegular() {
srcf, err := os.Open(fn)
if err != nil {
return err
}
dstf, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
_, err = io.Copy(dstf, srcf)
if err != nil {
return err
}
err = srcf.Close()
if err != nil {
return err
}
err = dstf.Close()
if err != nil {
return err
}
err = os.Chmod(dest, fi.Mode())
if err != nil {
return err
}
destMT := fi.ModTime()
return os.Chtimes(dest, destMT, destMT)
}
if fi.Mode().IsDir() {
dirs = append(dirs, &dirEntry{dest, fi.Mode().Perm(), fi.ModTime()})
return os.Mkdir(dest, 0700)
}
return nil
})
if err != nil {
return err
}
for i := len(dirs) - 1; i >= 0; i-- {
err := os.Chmod(dirs[i].name, dirs[i].perm)
if err != nil {
return err
}
err = os.Chtimes(dirs[i].name, dirs[i].time, dirs[i].time)
if err != nil {
return err
}
}
return nil
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"bufio"
"io"
"os"
"regexp"
"strconv"
"time"
)
type emptyErr struct {
error
}
// TreeCreate parses a suplied Reader for the tree information
// and follows the instructions to create files and directories.
//
// The input has line records with three or four fields
// separated by one or more tabs. White space is trimmed on
// both ends of lines. Empty lines are skipped. The general
// line format is:
//
// <1. time> <2. permissions> <3. name> <4. optional content>
//
// Field 1: Time in RFC3339 format, as shown at
// https://golang.org/pkg/time/#RFC3339
//
// Field 2: Octal (required) representation of FileMode, as at
// https://golang.org/pkg/os/#FileMode
//
// Field 3: is the file or directory path to be created. If the
// first character of the path is a double-quote or a back-tick,
// then the path wil be passed through strconv.Unquote() function.
// It allows for using tab-containing or otherwise weird names.
// The quote or back-tick should be balanced at the end of
// the field.
//
// If the path in Field 3 ends with a forward slash, then it is
// treated as a directory, otherwise - as a regular file.
//
// Field 4: is optional content to be written into the file. It
// follows the same quotation rules as paths in Field 3.
// Directory entries ignore Field 4 if present.
//
// It is up to the caller to deal with conflicting file and
// directory names in the input. TreeCreate processes the
// input line-by-line and will return with error at a first
// problem it runs into.
func TreeCreate(config io.Reader) error {
dirs := make([]*dirEntry, 0)
scanner := bufio.NewScanner(config)
for scanner.Scan() {
mt, perm, name, content, err := parse(scanner.Text())
if err != nil {
if _, ok := err.(*emptyErr); ok {
continue
}
return err
}
if name[len(name)-1] == '/' {
name = name[:len(name)-1]
if err = os.Mkdir(name, 0700); err != nil {
return err
}
dirs = append(dirs, &dirEntry{name, perm, mt})
continue
}
f, err := os.Create(name)
if err != nil {
return err
}
if len(content) > 0 {
_, err = f.WriteString(content)
if err != nil {
return err
}
}
err = f.Close()
if err != nil {
return err
}
err = os.Chmod(name, perm)
if err != nil {
return err
}
err = os.Chtimes(name, mt, mt)
if err != nil {
return err
}
}
err := scanner.Err()
if err != nil {
return err
}
for i := len(dirs) - 1; i >= 0; i-- {
err := os.Chmod(dirs[i].name, dirs[i].perm)
if err != nil {
return err
}
err = os.Chtimes(dirs[i].name, dirs[i].time, dirs[i].time)
if err != nil {
return err
}
}
return nil
}
var (
re = regexp.MustCompile(`^\s*([-0-9T:Z]+)\t+(0[0-7]{0,4})\t+([^\t]+)(\t+([^\t]+))?\s*$`)
empty = regexp.MustCompile(`^\s*$`)
)
func parse(line string) (time.Time, os.FileMode, string, string, error) {
if empty.MatchString(line) {
return time.Time{}, 0, "", "", &emptyErr{}
}
parts := re.FindStringSubmatch(line)
mt, err := time.Parse(time.RFC3339, parts[1])
if err != nil {
return time.Time{}, 0, "", "", err
}
mt = mt.Round(0)
perm64, err := strconv.ParseUint(parts[2], 8, 32)
if err != nil {
return time.Time{}, 0, "", "", err
}
perm := os.FileMode(perm64)
var path string
if parts[3][0] == '`' || parts[3][0] == '"' {
path, err = strconv.Unquote(parts[3])
if err != nil {
return time.Time{}, 0, "", "", err
}
} else {
path = parts[3]
}
var content string
if len(parts[5]) > 0 {
if parts[5][0] == '`' || parts[5][0] == '"' {
content, err = strconv.Unquote(parts[5])
if err != nil {
return time.Time{}, 0, "", "", err
}
} else {
content = parts[5]
}
}
return mt, perm, path, content, nil
}
// Copyright 2017 Vlad Didenko. All rights reserved.
// See the included LICENSE.md file for licensing information
package fst // import "go.didenko.com/fst"
import (
"fmt"
"os"
"path/filepath"
)
// TreeDiff produces a slice of human-readable notes about
// recursive differences between two directory trees on a
// filesystem. Only plan directories and plain files are
// compared in the tree. Specific comparisons are determined
// By the variadic slice of FileRank functions, like the
// ones in this package. A commonly used set of comparators
// is ByName, ByDir, BySize, and ByContent
func TreeDiff(a string, b string, comps ...FileRank) ([]string, error) {
listA, err := collectFileInfo(a)
if err != nil {
return nil, err
}
listB, err := collectFileInfo(b)
if err != nil {
return nil, err
}
onlyA, onlyB := collectDifferent(listA, listB, comps...)
var diags []string
if len(onlyA) > 0 {
diagA := fmt.Sprintf("Unique items from \"%s\": \n", a)
for _, fi := range onlyA {
diagA = diagA + fmt.Sprintf("dir:%v, sz:%v, mode:%v, time:%v, name: %v\n", fi.IsDir(), fi.Size(), fi.Mode(), fi.ModTime(), fi.Name())
}
diags = append(diags, diagA)
}
if len(onlyB) > 0 {
diagB := fmt.Sprintf("Unique items from \"%s\": \n", b)
for _, fi := range onlyB {
diagB = diagB + fmt.Sprintf("dir:%v, sz:%v, mode:%v, time:%v, name: %v\n", fi.IsDir(), fi.Size(), fi.Mode(), fi.ModTime(), fi.Name())
}
diags = append(diags, diagB)
}
return diags, nil
}
// collectDifferent forms file infomation slices for files
// unique to either left or right collections. It is based
// on a modified algorithm from the go.didenko.com/slops package
func collectDifferent(left, right []*FileInfoPath, comps ...FileRank) (onlyLeft, onlyRight []*FileInfoPath) {
onlyLeft = make([]*FileInfoPath, 0)
onlyRight = make([]*FileInfoPath, 0)
for l, r := 0, 0; l < len(left) || r < len(right); {
if r < len(right) && (l == len(left) || Less(right[r], left[l], comps...)) {
onlyRight = append(onlyRight, right[r])
r++
continue
}
if l < len(left) && (r == len(right) || Less(left[l], right[r], comps...)) {
onlyLeft = append(onlyLeft, left[l])
l++
continue
}
l++
r++
}
return onlyLeft, onlyRight
}
func collectFileInfo(dir string) ([]*FileInfoPath, error) {
list := make([]*FileInfoPath, 0)
err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
if err == nil && path != dir {
list = append(list, &FileInfoPath{f, path})
}
return err
})
return list, err
}