5 Commits

5 changed files with 332 additions and 43 deletions

View File

@@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"sort"
"strings"
"sync" "sync"
) )
@@ -15,7 +17,9 @@ type FileSystem interface {
MkdirAll(path string, perm os.FileMode) error MkdirAll(path string, perm os.FileMode) error
Symlink(source, target string) error Symlink(source, target string) error
Link(source, target string) error Link(source, target string) error
RecordLinkAttempt(kind string, source, target string, err error, dryRun bool)
SummaryLines() []string SummaryLines() []string
SummaryRecords() []operationRecord
IsDryRun() bool IsDryRun() bool
} }
@@ -36,23 +40,96 @@ type operationRecord struct {
} }
func (r operationRecord) summaryLine() (string, bool) { func (r operationRecord) summaryLine() (string, bool) {
if r.kind != opSymlink && r.kind != opHardlink { detail := r.detail()
return "", false if detail != "" {
return fmt.Sprintf("%s %s → %s (%s) %s",
r.kindLabel(),
r.formattedSource(),
r.formattedTarget(),
r.resultLabel(),
detail), true
} }
kindLabel := "Symlink" return fmt.Sprintf("%s %s → %s (%s)",
if r.kind == opHardlink { r.kindLabel(),
kindLabel = "Hardlink" r.formattedSource(),
} r.formattedTarget(),
r.resultLabel()), true
}
status := "OK" func (r operationRecord) kindLabel() string {
switch r.kind {
case opSymlink:
return "Symlink"
case opHardlink:
return "Hardlink"
case opRemove:
return "Remove"
case opRemoveAll:
return "RemoveAll"
case opMkdirAll:
return "MkdirAll"
default:
return r.kind
}
}
func (r operationRecord) resultLabel() string {
if r.err != nil { if r.err != nil {
status = fmt.Sprintf("FAIL: %v", r.err) return fmt.Sprintf("%sFAIL%s", BRed, Reset)
} else if r.dryRun {
status = "DRY-RUN"
} }
if r.dryRun {
return fmt.Sprintf("%sDRY-RUN%s", BCyan, Reset)
}
return fmt.Sprintf("%sOK%s", BGreen, Reset)
}
return fmt.Sprintf("%s %s -> %s (%s)", kindLabel, r.source, r.target, status), true func (r operationRecord) resultLabelPlain() string {
if r.err != nil {
return "FAIL"
}
if r.dryRun {
return "DRY-RUN"
}
return "OK"
}
func (r operationRecord) formattedSource() string {
if r.source == "" {
return "-"
}
return FormatSourcePath(r.source)
}
func (r operationRecord) plainSource() string {
if r.source == "" {
return "-"
}
return r.source
}
func (r operationRecord) formattedTarget() string {
if r.target == "" {
return "-"
}
return FormatTargetPath(r.target)
}
func (r operationRecord) plainTarget() string {
if r.target == "" {
return "-"
}
return r.target
}
func (r operationRecord) detail() string {
if r.err != nil {
return r.err.Error()
}
if r.dryRun {
return "dry-run"
}
return ""
} }
type baseFileSystem struct { type baseFileSystem struct {
@@ -119,20 +196,28 @@ func (fs *realFileSystem) MkdirAll(path string, perm os.FileMode) error {
func (fs *realFileSystem) Symlink(source, target string) error { func (fs *realFileSystem) Symlink(source, target string) error {
err := os.Symlink(source, target) err := os.Symlink(source, target)
fs.addOperation(opSymlink, source, target, err, false) fs.RecordLinkAttempt(opSymlink, source, target, err, false)
return err return err
} }
func (fs *realFileSystem) Link(source, target string) error { func (fs *realFileSystem) Link(source, target string) error {
err := os.Link(source, target) err := os.Link(source, target)
fs.addOperation(opHardlink, source, target, err, false) fs.RecordLinkAttempt(opHardlink, source, target, err, false)
return err return err
} }
func (fs *realFileSystem) RecordLinkAttempt(kind string, source, target string, err error, dryRun bool) {
fs.addOperation(kind, source, target, err, dryRun)
}
func (fs *realFileSystem) SummaryLines() []string { func (fs *realFileSystem) SummaryLines() []string {
return summarizeOperations(fs.snapshot()) return summarizeOperations(fs.snapshot())
} }
func (fs *realFileSystem) SummaryRecords() []operationRecord {
return fs.snapshot()
}
func (fs *realFileSystem) IsDryRun() bool { func (fs *realFileSystem) IsDryRun() bool {
return false return false
} }
@@ -162,19 +247,149 @@ func (fs *dryRunFileSystem) MkdirAll(path string, perm os.FileMode) error {
} }
func (fs *dryRunFileSystem) Symlink(source, target string) error { func (fs *dryRunFileSystem) Symlink(source, target string) error {
fs.addOperation(opSymlink, source, target, nil, true) fs.RecordLinkAttempt(opSymlink, source, target, nil, true)
return nil return nil
} }
func (fs *dryRunFileSystem) Link(source, target string) error { func (fs *dryRunFileSystem) Link(source, target string) error {
fs.addOperation(opHardlink, source, target, nil, true) fs.RecordLinkAttempt(opHardlink, source, target, nil, true)
return nil return nil
} }
func (fs *dryRunFileSystem) RecordLinkAttempt(kind string, source, target string, err error, dryRun bool) {
fs.addOperation(kind, source, target, err, dryRun)
}
func (fs *dryRunFileSystem) SummaryLines() []string { func (fs *dryRunFileSystem) SummaryLines() []string {
return summarizeOperations(fs.snapshot()) return summarizeOperations(fs.snapshot())
} }
func (fs *dryRunFileSystem) SummaryRecords() []operationRecord {
return fs.snapshot()
}
func (fs *dryRunFileSystem) IsDryRun() bool { func (fs *dryRunFileSystem) IsDryRun() bool {
return true return true
} }
func BuildSummaryLines(records []operationRecord) []string {
if len(records) == 0 {
return nil
}
sorted := make([]operationRecord, len(records))
copy(sorted, records)
opOrder := map[string]int{
opRemove: 0,
opRemoveAll: 1,
opMkdirAll: 2,
opSymlink: 3,
opHardlink: 4,
}
sort.SliceStable(sorted, func(i, j int) bool {
ti := strings.ToLower(sorted[i].plainTarget())
tj := strings.ToLower(sorted[j].plainTarget())
if ti != tj {
return ti < tj
}
si := strings.ToLower(sorted[i].plainSource())
sj := strings.ToLower(sorted[j].plainSource())
if si != sj {
return si < sj
}
oi := opOrderValue(sorted[i].kind, opOrder)
oj := opOrderValue(sorted[j].kind, opOrder)
if oi != oj {
return oi < oj
}
return sorted[i].detail() < sorted[j].detail()
})
header := []string{"RESULT", "OPERATION", "SOURCE", "TARGET", "DETAIL"}
widths := make([]int, len(header))
for i, h := range header {
widths[i] = len(h)
}
plainRows := make([][]string, len(sorted))
coloredRows := make([][]string, len(sorted))
for i, record := range sorted {
detail := record.detail()
if detail == "" {
detail = "-"
}
plain := []string{
record.resultLabelPlain(),
record.kindLabel(),
record.plainSource(),
record.plainTarget(),
detail,
}
colored := []string{
record.resultLabel(),
record.kindLabel(),
record.formattedSource(),
record.formattedTarget(),
detail,
}
plainRows[i] = plain
coloredRows[i] = colored
for j, val := range plain {
if val == "" {
val = "-"
}
if len(val) > widths[j] {
widths[j] = len(val)
}
}
}
lines := make([]string, 0, len(sorted)+1)
lines = append(lines, formatSummaryRow(header, header, widths))
for i := range coloredRows {
lines = append(lines, formatSummaryRow(coloredRows[i], plainRows[i], widths))
}
return lines
}
func formatSummaryRow(colored, plain []string, widths []int) string {
var b strings.Builder
for i := range colored {
p := plain[i]
if p == "" {
p = "-"
}
col := colored[i]
if col == "" {
col = p
}
pad := widths[i] - len(p)
if pad < 0 {
pad = 0
}
b.WriteString(col)
if pad > 0 {
b.WriteString(strings.Repeat(" ", pad))
}
if i < len(colored)-1 {
b.WriteString(" ")
}
}
return b.String()
}
func opOrderValue(kind string, order map[string]int) int {
if v, ok := order[kind]; ok {
return v
}
return len(order)
}

19
filesystem_test.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSummaryLineMarksFailuresInRed(t *testing.T) {
fs := NewRealFileSystem()
fs.RecordLinkAttempt(opSymlink, "/tmp/source", "/tmp/target", fmt.Errorf("boom"), false)
lines := fs.SummaryLines()
assert.Equal(t, 1, len(lines))
assert.Contains(t, lines[0], BRed+"FAIL"+Reset)
assert.Contains(t, lines[0], "boom")
}

View File

@@ -177,19 +177,28 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
return return
} }
linkKind := opSymlink
if instruction.Hard {
linkKind = opHardlink
}
reportErr := func(err error) {
filesystem.RecordLinkAttempt(linkKind, instruction.Source, instruction.Target, err, filesystem.IsDryRun())
status <- err
}
if !FileExists(instruction.Source) { if !FileExists(instruction.Source) {
status <- fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source)) reportErr(fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source)))
return return
} }
if instruction.Files { if instruction.Files {
info, err := os.Stat(instruction.Source) info, err := os.Stat(instruction.Source)
if err != nil { if err != nil {
status <- fmt.Errorf("could not stat source %s; err: %v", FormatSourcePath(instruction.Source), err) reportErr(fmt.Errorf("could not stat source %s; err: %v", FormatSourcePath(instruction.Source), err))
return return
} }
if info.IsDir() { if info.IsDir() {
status <- fmt.Errorf("source %s is a directory but files=true", FormatSourcePath(instruction.Source)) reportErr(fmt.Errorf("source %s is a directory but files=true", FormatSourcePath(instruction.Source)))
return return
} }
} }
@@ -207,31 +216,31 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
if FileExists(instruction.Target) { if FileExists(instruction.Target) {
if instruction.Files { if instruction.Files {
if info, err := os.Stat(instruction.Target); err == nil && info.IsDir() { if info, err := os.Stat(instruction.Target); err == nil && info.IsDir() {
status <- fmt.Errorf("target %s is a directory but files=true", FormatTargetPath(instruction.Target)) reportErr(fmt.Errorf("target %s is a directory but files=true", FormatTargetPath(instruction.Target)))
return return
} }
} }
if instruction.Force { if instruction.Force {
isSymlink, err := IsSymlink(instruction.Target) isSymlink, err := IsSymlink(instruction.Target)
if err != nil { if err != nil {
status <- fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v", reportErr(fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v",
FormatTargetPath(instruction.Target), err) FormatTargetPath(instruction.Target), err))
return return
} }
if instruction.Hard { if instruction.Hard {
info, err := os.Stat(instruction.Target) info, err := os.Stat(instruction.Target)
if err != nil { if err != nil {
status <- fmt.Errorf("could not stat %s, stopping; err: %v", reportErr(fmt.Errorf("could not stat %s, stopping; err: %v",
FormatTargetPath(instruction.Target), err) FormatTargetPath(instruction.Target), err))
return return
} }
if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) { if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
LogTarget("Overwriting existing file %s", instruction.Target) LogTarget("Overwriting existing file %s", instruction.Target)
err := filesystem.Remove(instruction.Target) err := filesystem.Remove(instruction.Target)
if err != nil { if err != nil {
status <- fmt.Errorf("could not remove existing file %s; err: %v", reportErr(fmt.Errorf("could not remove existing file %s; err: %v",
FormatTargetPath(instruction.Target), err) FormatTargetPath(instruction.Target), err))
return return
} }
} }
@@ -241,27 +250,27 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
LogTarget("Removing symlink at %s", instruction.Target) LogTarget("Removing symlink at %s", instruction.Target)
err = filesystem.Remove(instruction.Target) err = filesystem.Remove(instruction.Target)
if err != nil { if err != nil {
status <- fmt.Errorf("failed deleting %s due to %v", reportErr(fmt.Errorf("failed deleting %s due to %v",
FormatTargetPath(instruction.Target), err) FormatTargetPath(instruction.Target), err))
return return
} }
} else { } else {
if !instruction.Delete { if !instruction.Delete {
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s", reportErr(fmt.Errorf("refusing to delte actual (non symlink) file %s",
FormatTargetPath(instruction.Target)) FormatTargetPath(instruction.Target)))
return return
} }
LogImportant("Deleting (!!!) %s", instruction.Target) LogImportant("Deleting (!!!) %s", instruction.Target)
err = filesystem.RemoveAll(instruction.Target) err = filesystem.RemoveAll(instruction.Target)
if err != nil { if err != nil {
status <- fmt.Errorf("failed deleting %s due to %v", reportErr(fmt.Errorf("failed deleting %s due to %v",
FormatTargetPath(instruction.Target), err) FormatTargetPath(instruction.Target), err))
return return
} }
} }
} else { } else {
status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)", reportErr(fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
FormatTargetPath(instruction.Target)) FormatTargetPath(instruction.Target)))
return return
} }
} }
@@ -270,8 +279,8 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
if _, err := os.Stat(targetDir); os.IsNotExist(err) { if _, err := os.Stat(targetDir); os.IsNotExist(err) {
err = filesystem.MkdirAll(targetDir, 0755) err = filesystem.MkdirAll(targetDir, 0755)
if err != nil { if err != nil {
status <- fmt.Errorf("failed creating directory %s due to %v", reportErr(fmt.Errorf("failed creating directory %s due to %v",
FormatTargetPath(targetDir), err) FormatTargetPath(targetDir), err))
return return
} }
} }

View File

@@ -420,8 +420,8 @@ func TestLinkInstruction_RunAsync(t *testing.T) {
summary := filesystem.SummaryLines() summary := filesystem.SummaryLines()
assert.Equal(t, 1, len(summary)) assert.Equal(t, 1, len(summary))
assert.Contains(t, summary[0], "DRY-RUN") assert.Contains(t, summary[0], "DRY-RUN")
assert.Contains(t, summary[0], srcFile) assert.Contains(t, summary[0], FormatSourcePath(srcFile))
assert.Contains(t, summary[0], target) assert.Contains(t, summary[0], FormatTargetPath(target))
}) })
} }

58
main.go
View File

@@ -3,10 +3,12 @@ package main
import ( import (
"bufio" "bufio"
"flag" "flag"
"fmt"
"io" "io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"sync/atomic" "sync/atomic"
utils "git.site.quack-lab.dev/dave/cyutils" utils "git.site.quack-lab.dev/dave/cyutils"
@@ -20,18 +22,27 @@ const ImportantColor = BRed
const DefaultColor = Reset const DefaultColor = Reset
const PathColor = Green const PathColor = Green
var programName = os.Args[0] var (
var undo = false programName = os.Args[0]
undo = false
version = "dev"
)
func main() { func main() {
recurse := flag.String("r", "", "recurse into directories") recurse := flag.String("r", "", "recurse into directories")
file := flag.String("f", "", "file to read instructions from") file := flag.String("f", "", "file to read instructions from")
debug := flag.Bool("d", false, "debug") debug := flag.Bool("d", false, "debug")
undoF := flag.Bool("u", false, "undo") undoF := flag.Bool("u", false, "undo")
versionFlag := flag.Bool("v", false, "print version and exit")
dryRun := flag.Bool("n", false, "dry run (no filesystem changes)") dryRun := flag.Bool("n", false, "dry run (no filesystem changes)")
flag.Parse() flag.Parse()
undo = *undoF undo = *undoF
if *versionFlag {
fmt.Println(getVersionString())
return
}
setupLogging(*debug) setupLogging(*debug)
if *dryRun { if *dryRun {
@@ -174,10 +185,7 @@ func processInstructions(instructions chan *LinkInstruction) int32 {
} }
func printSummary(fs FileSystem) { func printSummary(fs FileSystem) {
lines := fs.SummaryLines() lines := BuildSummaryLines(fs.SummaryRecords())
if len(lines) == 0 {
return
}
LogInfo("Summary:") LogInfo("Summary:")
for _, line := range lines { for _, line := range lines {
LogInfo("%s", line) LogInfo("%s", line)
@@ -248,6 +256,44 @@ func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status
} }
} }
func getVersionString() string {
if version != "" && version != "dev" {
return version
}
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
var revision, modified, vcsTime string
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
revision = setting.Value
case "vcs.modified":
modified = setting.Value
case "vcs.time":
vcsTime = setting.Value
}
}
if revision != "" {
if len(revision) > 7 {
revision = revision[:7]
}
if modified == "true" {
revision += "-dirty"
}
if vcsTime != "" {
revision += " (" + vcsTime + ")"
}
return revision
}
}
return "dev"
}
func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) { func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
if doclose { if doclose {
defer close(output) defer close(output)