4 Commits

Author SHA1 Message Date
2586386cc3 Fix up the summary to log as a table 2025-11-20 15:59:14 +01:00
cb53596c46 Log errors in red in summary 2025-11-20 15:41:36 +01:00
88ef93e9af Fix up the filesystem summary logger 2025-11-20 15:37:58 +01:00
610ba20276 Add version flag 2025-11-20 15:32:59 +01:00
5 changed files with 251 additions and 42 deletions

View File

@@ -15,7 +15,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 +38,72 @@ 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) formattedSource() string {
if r.source == "" {
return "-"
}
return FormatSourcePath(r.source)
}
func (r operationRecord) formattedTarget() string {
if r.target == "" {
return "-"
}
return FormatTargetPath(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 +170,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 +221,27 @@ 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
} }

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))
}) })
} }

124
main.go
View File

@@ -3,10 +3,14 @@ package main
import ( import (
"bufio" "bufio"
"flag" "flag"
"fmt"
"io" "io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime/debug"
"strings"
"sync/atomic" "sync/atomic"
utils "git.site.quack-lab.dev/dave/cyutils" utils "git.site.quack-lab.dev/dave/cyutils"
@@ -20,18 +24,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,14 +187,77 @@ func processInstructions(instructions chan *LinkInstruction) int32 {
} }
func printSummary(fs FileSystem) { func printSummary(fs FileSystem) {
lines := fs.SummaryLines() records := fs.SummaryRecords()
if len(lines) == 0 { if len(records) == 0 {
return return
} }
header := []string{"RESULT", "OPERATION", "SOURCE", "TARGET", "DETAIL"}
widths := make([]int, len(header))
for i, h := range header {
widths[i] = len(h)
}
type row struct {
values []string
err error
}
rows := make([]row, len(records))
for i, record := range records {
values := []string{
record.resultLabel(),
record.kindLabel(),
FormatSourcePath(record.source),
FormatTargetPath(record.target),
record.detail(),
}
rows[i] = row{values: values, err: record.err}
for j, val := range values {
if l := visibleLength(val); l > widths[j] {
widths[j] = l
}
}
}
LogInfo("Summary:") LogInfo("Summary:")
for _, line := range lines { var lineBuilder strings.Builder
writeSummaryRow(&lineBuilder, header, widths)
for _, line := range strings.Split(strings.TrimRight(lineBuilder.String(), "\n"), "\n") {
LogInfo("%s", line) LogInfo("%s", line)
} }
for _, r := range rows {
lineBuilder.Reset()
writeSummaryRow(&lineBuilder, r.values, widths)
line := strings.TrimRight(lineBuilder.String(), "\n")
LogInfo("%s", line)
}
}
func writeSummaryRow(b *strings.Builder, cols []string, widths []int) {
for i, val := range cols {
if val == "" {
val = "-"
}
b.WriteString(val)
pad := widths[i] - visibleLength(val)
if pad < 0 {
pad = 0
}
if i < len(cols)-1 {
b.WriteString(strings.Repeat(" ", pad+2))
}
}
b.WriteByte('\n')
}
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func visibleLength(s string) int {
return len(ansiRegexp.ReplaceAllString(s, ""))
} }
func IsPipeInput() bool { func IsPipeInput() bool {
@@ -248,6 +324,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)