aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md12
-rw-r--r--ccp.go117
-rw-r--r--ccp.pngbin0 -> 23436 bytes
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--internal/cp/cp.go102
-rw-r--r--internal/wfs/osfs/osfs.go2
-rw-r--r--internal/wfs/sftpfs/sftpfs.go21
-rw-r--r--internal/wfs/wfs.go84
9 files changed, 270 insertions, 74 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2bc7067
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# ccp: a cute terminal file copy utility
+
+![ccp displays a colorful progress bar in the terminal](ccp.png)
+
+ccp is your new favorite replacement for scp.
+
+ - ✨ Colorful progress bar using [Bubble Tea](https://github.com/charmbracelet/bubbletea)
+ - 📂 Copy directories recursively
+ - ⏳ Shows estimated time remaining
+ - 🌐 Transfer files to and from network servers with SFTP
+ - 🔐 Supports SSH public keys
+ - 🔥 Written in blazingly fast Go
diff --git a/ccp.go b/ccp.go
index 743bdd7..ed16fc3 100644
--- a/ccp.go
+++ b/ccp.go
@@ -1,3 +1,32 @@
+// The ccp ("cute copy") command copies files and directories while showing a
+// colorful progress bar. It supports SFTP remote file copies similar to scp.
+//
+// The architecture is a mix of classical goroutines and the bubbletea-style
+// "Elm architecture". Trying to do a recursive concurrent file copy using the
+// Elm architecture would make Update a massive bottleneck, so that part is
+// performed in a background goroutine that periodically sends updates to the
+// main program using the [cp.Progress] interface.
+//
+// The Elm architecture doesn't seem to fit well with Go's concurrency model in
+// my opinion. You even have articles like
+// https://charm.sh/blog/commands-in-bubbletea/ saying that you should "never"
+// use goroutines in a Bubble Tea program, which IMO is just absurd and throwing
+// out one of the best parts of Go. Ideally a UI library would leverage the
+// strengths of Go's concurrency model instead of trying to force some
+// architecture from a different language. For example, [tea.Tick] is
+// inconvenient because the user has to remember to call Tick again inside
+// Update, otherwise it only runs once. Instead it could have just leveraged the
+// standard library [time.Ticker] with
+//
+// go func() {
+// for t := range time.NewTicker(time.Second).C {
+// program.Send(tickMsg(t))
+// }
+// }()
+//
+// It's too limiting that a [tea.Cmd] can only return a single [tea.Msg].
+// Instead, in the true spirit of Go's CSP model, a tea.Cmd should be able to
+// send multiple messages on a channel. Thanks for reading my rant.
package main
import (
@@ -28,31 +57,55 @@ type measurement struct {
type model struct {
progress progress.Model
- max int64
- current atomic.Int64
+ // max is the total bytes (plus fudge factor) to copy.
+ max int64
+ // current holds the current number of copied bytes.
+ current atomic.Int64
+ // Every 500 milliseconds, the current progress is appended to
+ // measurements for calculating ETA.
measurements deque.Deque[measurement]
+ // copyingFiles holds the files currently being copied. Keys are source
+ // paths and values are the corresponding destination paths.
copyingFiles map[string]string
- copyingFile string
- errs []string
- done bool
+ // copyingFile is an arbitrary entry from copyingFiles that we're
+ // currently showing to the user. Tracked in the state so that it
+ // doesn't change every time we update the view.
+ copyingFile string
+ // eta is the estimated time to completion, or -1 if we don't have
+ // enough samples.
+ eta time.Duration
+ // errs are the errors encountered during operation.
+ errs []string
+ // done indicates whether the copy is done and we're just waiting for
+ // the progress bar to finish animating.
+ done bool
}
type (
+ // tickMsg is sent every 500 milliseconds.
tickMsg time.Time
- maxMsg int64
+ // maxMsg sets the total bytes to copy. This message is only sent once
+ // during the program lifetime after we asynchronously calculate the
+ // number of bytes to copy.
+ maxMsg int64
+ // fileStartMsg is sent whenever we start copying a file.
fileStartMsg struct {
from, to string
}
+ // fileDoneMsg is sent whenever we finish copying a file. err indicates
+ // any error that was encountered during the copy.
fileDoneMsg struct {
name string
err error
}
+ // doneMsg is sent when all files are finished copying and it's time
+ // to exit.
doneMsg struct{}
)
func tick() tea.Cmd {
- return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
+ return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m *model) Init() tea.Cmd {
@@ -94,10 +147,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg:
n := m.current.Load()
now := time.Time(msg)
- for m.measurements.Len() > 1 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
- m.measurements.PopFront()
+
+ if m.measurements.Len() == 0 || now.Sub(m.measurements.At(m.measurements.Len()-1).t) > 500*time.Millisecond {
+ for m.measurements.Len() > 1 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
+ m.measurements.PopFront()
+ }
+ m.measurements.PushBack(measurement{now, n})
+
+ if m.max > 0 {
+ first := m.measurements.At(0)
+ if delta := n - first.i; delta != 0 {
+ deltaT := now.Sub(first.t)
+ m.eta = time.Duration(float64(m.max-n) / float64(delta) * float64(deltaT))
+ }
+ }
}
- m.measurements.PushBack(measurement{now, n})
+
cmds := []tea.Cmd{tick()}
if m.max > 0 {
cmds = append(cmds, m.progress.SetPercent(float64(n)/float64(m.max)))
@@ -126,14 +191,8 @@ func (m *model) View() string {
copying = m.copyingFile + " -> " + m.copyingFiles[m.copyingFile]
}
etaStr := "calculating..."
- if m.max > 0 && m.measurements.Len() > 1 {
- first := m.measurements.At(0)
- last := m.measurements.At(m.measurements.Len() - 1)
- deltaT := last.t.Sub(first.t)
- delta := last.i - first.i
- if delta != 0 {
- etaStr = time.Duration(float64(m.max-last.i) / float64(delta) * float64(deltaT)).Round(time.Second).String()
- }
+ if m.eta >= 0 {
+ etaStr = m.eta.Round(time.Second).String()
}
return "\n" +
" " + copying + "\n" +
@@ -142,6 +201,7 @@ func (m *model) View() string {
warningStyle(strings.Join(m.errs, "\n")) + "\n"
}
+// progressUpdater implements the cp.Progress interface.
type progressUpdater struct {
p *tea.Program
current *atomic.Int64
@@ -163,6 +223,9 @@ func (pu *progressUpdater) FileDone(name string, err error) {
pu.p.Send(fileDoneMsg{name, err})
}
+// splitHostPath splits an scp target into host and path, e.g. user@host:/path/
+// If the user wants to copy a local file that has a colon in it, they can
+// qualify it with the directory name, e.g. ./file:with:colons.
func splitHostPath(target string) (string, string) {
i := strings.IndexAny(target, ":/")
if i < 0 || target[i] == '/' {
@@ -209,10 +272,11 @@ func run() error {
m := &model{
progress: progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()),
copyingFiles: make(map[string]string),
+ eta: -1,
}
p := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(os.Stderr))
go func() {
- cp.Copy(&progressUpdater{p, &m.current}, srcs, dst, *f)
+ cp.Copy(&progressUpdater{p, &m.current}, srcs, dst, *f) // Where the magic happens
p.Send(doneMsg{})
}()
if _, err := p.Run(); err != nil {
@@ -226,10 +290,19 @@ func run() error {
func main() {
flag.Usage = func() {
- fmt.Fprintf(os.Stderr, `Usage: ccp [OPTION]... SOURCE DEST
- or: ccp [OPTION]... SOURCE... DIRECTORY
+ fmt.Fprintf(os.Stderr, `Usage: ccp [OPTION]... SOURCE TARGET
+ or: ccp [OPTION]... SOURCE... TARGET
+
+Copy SOURCE to TARGET, or multiple SOURCE(s) to a directory TARGET.
+Uses SFTP for remote file copies.
+
+ccp will ask for passwords or passphrases if they are needed
+for authentication.
-Copy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.
+The source and target may be specified as a local pathname or a remote
+host with optional path in the form [user@]host:[path]. Local file names
+can be made explicit using absolute or relative pathnames to avoid ccp
+treating file names containing `+"`"+`:' as host specifiers.
`)
flag.PrintDefaults()
diff --git a/ccp.png b/ccp.png
new file mode 100644
index 0000000..58b1fc0
--- /dev/null
+++ b/ccp.png
Binary files differ
diff --git a/go.mod b/go.mod
index 816c326..348e1cb 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/pkg/sftp v1.13.9
- gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030
+ gitlab.com/rhogenson/deque v1.0.0
golang.org/x/crypto v0.37.0
golang.org/x/term v0.31.0
)
diff --git a/go.sum b/go.sum
index f73ca01..33d9bfe 100644
--- a/go.sum
+++ b/go.sum
@@ -53,8 +53,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030 h1:ElfzUwCcfm4HHxJkMfoEn5w8VOoI+XRYfa9Vdo7Plno=
-gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030/go.mod h1:+JcaVhyJ7VgoRMo4PsMgOCDoVnIOLts4CkDEAYkU4o4=
+gitlab.com/rhogenson/deque v1.0.0 h1:lRaSCwq/1L93bEBMr/i5e20Lo0k+YChPCw+3QGrb1x4=
+gitlab.com/rhogenson/deque v1.0.0/go.mod h1:+JcaVhyJ7VgoRMo4PsMgOCDoVnIOLts4CkDEAYkU4o4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
diff --git a/internal/cp/cp.go b/internal/cp/cp.go
index 7350343..66313c7 100644
--- a/internal/cp/cp.go
+++ b/internal/cp/cp.go
@@ -1,3 +1,5 @@
+// Package cp implements a concurrent file copy over the abstract [wfs.FS]
+// interface. It reports progress and errors using the [Progress] interface.
package cp
import (
@@ -13,14 +15,26 @@ import (
"gitlab.com/rhogenson/ccp/internal/wfs/sftpfs"
)
+// Progress is used to asynchronously report status updates and errors to the
+// main program.
type Progress interface {
+ // Max sets the total number of bytes to be copied. It's expected that
+ // this will only be called once in the program lifetime.
Max(int64)
- Progress(int64)
- FileStart(string, string)
- FileDone(string, error)
+ // Progress reports that n additional bytes have been copied.
+ Progress(n int64)
+ // FileStart reports that src is currently being copied to dst. Only
+ // called for regular files, not directories or symlinks.
+ FileStart(src, dst string)
+ // FileDone is called when a regular file has finished copying
+ // successfully, or when there was an error copying a file.
+ FileDone(src string, err error)
}
+// An FSPath is an abstraction over a file path that can point to multiple
+// different backing filesystems.
type FSPath struct {
+ // FS is the backing file system where Path is valid.
FS wfs.FS
Path string
}
@@ -32,54 +46,59 @@ func (p FSPath) String() string {
return p.Path
}
-func (p FSPath) WalkDir(fn fs.WalkDirFunc) error {
+// These helper functions are useful to prevent mismatches between filesystem
+// and path. For example it's too easy to write
+//
+// src.FS.Open(dst.Path)
+
+func (p FSPath) walkDir(fn fs.WalkDirFunc) error {
return fs.WalkDir(p.FS, p.Path, fn)
}
-func (p FSPath) Stat() (fs.FileInfo, error) {
+func (p FSPath) stat() (fs.FileInfo, error) {
return fs.Stat(p.FS, p.Path)
}
-func (p FSPath) Lstat() (fs.FileInfo, error) {
+func (p FSPath) lstat() (fs.FileInfo, error) {
return wfs.Lstat(p.FS, p.Path)
}
-func (p FSPath) RemoveAll() error {
+func (p FSPath) removeAll() error {
return wfs.RemoveAll(p.FS, p.Path)
}
-func (p FSPath) Open() (fs.File, error) {
+func (p FSPath) open() (fs.File, error) {
return p.FS.Open(p.Path)
}
-func (p FSPath) Create(mode fs.FileMode) (io.WriteCloser, error) {
+func (p FSPath) create(mode fs.FileMode) (io.WriteCloser, error) {
return p.FS.Create(p.Path, mode)
}
-func (p FSPath) ReadLink() (string, error) {
+func (p FSPath) readLink() (string, error) {
return wfs.ReadLink(p.FS, p.Path)
}
-func (p FSPath) SymlinkFrom(target string) error {
+func (p FSPath) symlinkFrom(target string) error {
return p.FS.Symlink(target, p.Path)
}
-func (p FSPath) Mkdir() error {
+func (p FSPath) mkdir() error {
return p.FS.Mkdir(p.Path)
}
-func (p FSPath) MkdirMode(mode fs.FileMode) error {
+func (p FSPath) mkdirMode(mode fs.FileMode) error {
return wfs.MkdirMode(p.FS, p.Path, mode)
}
-func (p FSPath) Chmod(mode fs.FileMode) error {
+func (p FSPath) chmod(mode fs.FileMode) error {
return p.FS.Chmod(p.Path, mode)
}
func size(srcs []FSPath) int64 {
var n int64 = 0
for _, src := range srcs {
- src.WalkDir(func(_ string, d fs.DirEntry, err error) error {
+ src.walkDir(func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
@@ -89,7 +108,9 @@ func size(srcs []FSPath) int64 {
if err != nil {
return nil
}
- n += 1 + stat.Size()
+ // The "+ 1" is a fudge factor to make sure that
+ // the total number of bytes won't be zero.
+ n += stat.Size() + 1
case fs.ModeSymlink, fs.ModeDir:
n++
}
@@ -100,7 +121,7 @@ func size(srcs []FSPath) int64 {
}
func (p FSPath) exists() bool {
- _, err := p.Lstat()
+ _, err := p.lstat()
return !errors.Is(err, fs.ErrNotExist)
}
@@ -113,7 +134,7 @@ func (c *copier) openWithRetry(path FSPath, fn func() error) error {
if err := fn(); err == nil || !c.force || !path.exists() {
return err
}
- if err := path.RemoveAll(); err != nil {
+ if err := path.removeAll(); err != nil {
return err
}
return fn()
@@ -122,7 +143,7 @@ func (c *copier) openWithRetry(path FSPath, fn func() error) error {
func (c *copier) copyRegularFile(src, dst FSPath) error {
c.p.FileStart(src.String(), dst.String())
- in, err := src.Open()
+ in, err := src.open()
if err != nil {
return err
}
@@ -134,12 +155,14 @@ func (c *copier) copyRegularFile(src, dst FSPath) error {
var out io.WriteCloser
if err := c.openWithRetry(dst, func() error {
var err error
- out, err = dst.Create(stat.Mode().Perm())
+ out, err = dst.create(stat.Mode().Perm())
return err
}); err != nil {
return err
}
for {
+ // io.CopyN will use cool stuff like copy_file_range as long as
+ // the underlying types are *os.File
n, err := io.CopyN(out, in, 1024*1024)
if n > 0 {
c.p.Progress(n)
@@ -161,12 +184,12 @@ func (c *copier) copyRegularFile(src, dst FSPath) error {
}
func (c *copier) copySymlink(src FSPath, dst FSPath) error {
- target, err := src.ReadLink()
+ target, err := src.readLink()
if err != nil {
return err
}
if err := c.openWithRetry(dst, func() error {
- return dst.SymlinkFrom(target)
+ return dst.symlinkFrom(target)
}); err != nil {
return err
}
@@ -174,6 +197,9 @@ func (c *copier) copySymlink(src FSPath, dst FSPath) error {
return nil
}
+// Copy copies srcs into dstRoot, reporting progress using the [Progress]
+// interface. If force is specified and an existing destination file cannot be
+// opened, Copy will remove it and try again.
func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
done := make(chan struct{})
go func() {
@@ -184,7 +210,7 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
dstIsDir := true
if len(srcs) == 1 {
- stat, err := dstRoot.Stat()
+ stat, err := dstRoot.stat()
dstIsDir = err == nil && stat.IsDir()
}
@@ -200,13 +226,20 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
mode fs.FileMode
}
var roDirs []roDir
+ dstRoot.Path = path.Clean(dstRoot.Path)
for _, srcRoot := range srcs {
dstRoot := dstRoot
if dstIsDir {
+ // If the destination is a directory, copy into the
+ // existing directory.
dstRoot.Path = path.Join(dstRoot.Path, path.Base(srcRoot.Path))
}
srcRoot.Path = path.Clean(srcRoot.Path)
- srcRoot.WalkDir(func(srcPath string, d fs.DirEntry, err error) error {
+ if srcRoot == dstRoot {
+ progress.FileDone(srcRoot.String(), fmt.Errorf("%q and %q are the same file", srcRoot, dstRoot))
+ continue
+ }
+ srcRoot.walkDir(func(srcPath string, d fs.DirEntry, err error) error {
src := FSPath{srcRoot.FS, srcPath}
dst := FSPath{dstRoot.FS, path.Join(dstRoot.Path, strings.TrimPrefix(srcPath, srcRoot.Path))}
if err != nil {
@@ -214,7 +247,7 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
return nil
}
switch d.Type() {
- case 0:
+ case 0: // regular file
sem <- struct{}{}
go func() {
defer func() { <-sem }()
@@ -222,6 +255,7 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
progress.FileDone(src.String(), err)
}
}()
+
case fs.ModeDir:
stat, err := d.Info()
if err != nil {
@@ -231,9 +265,18 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
hasWritePerm := stat.Mode()&0300 == 0300
if err := c.openWithRetry(dst, func() error {
if hasWritePerm {
- return dst.MkdirMode(stat.Mode().Perm())
+ return dst.mkdirMode(stat.Mode().Perm())
} else {
- return dst.Mkdir()
+ // If a directory doesn't have
+ // write permissions, we won't
+ // be able to create any files
+ // inside of it if we create it
+ // with the correct permissions
+ // now. So instead create it
+ // with some default
+ // permissions, and append it to
+ // roDirs to be processed later.
+ return dst.mkdir()
}
}); err != nil {
progress.FileDone(src.String(), err)
@@ -254,11 +297,14 @@ func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
return nil
})
}
+ // Wait for all jobs to complete.
for range maxConcurrency {
sem <- struct{}{}
}
+ // Iterate backwards so that directory contents are processed before the
+ // parent directory itself.
for _, d := range slices.Backward(roDirs) {
- if err := d.path.Chmod(d.mode); err != nil {
+ if err := d.path.chmod(d.mode); err != nil {
progress.FileDone(d.path.String(), err)
continue
}
diff --git a/internal/wfs/osfs/osfs.go b/internal/wfs/osfs/osfs.go
index bf6951c..c879f85 100644
--- a/internal/wfs/osfs/osfs.go
+++ b/internal/wfs/osfs/osfs.go
@@ -1,3 +1,4 @@
+// Package osfs implements [wfs.FS] backed by the local filesystem.
package osfs
import (
@@ -15,6 +16,7 @@ var (
_ fs.StatFS = FS{}
)
+// An FS is a [wfs.FS] backed by the local filesystem.
type FS struct{}
func (FS) Open(name string) (fs.File, error) {
diff --git a/internal/wfs/sftpfs/sftpfs.go b/internal/wfs/sftpfs/sftpfs.go
index d7f0cdc..2ec9635 100644
--- a/internal/wfs/sftpfs/sftpfs.go
+++ b/internal/wfs/sftpfs/sftpfs.go
@@ -1,3 +1,4 @@
+// Package sftpfs implements [wfs.FS] using [github.com/pkg/sftp].
package sftpfs
import (
@@ -24,10 +25,11 @@ var (
_ wfs.FS = (*FS)(nil)
_ wfs.ReadLinkFS = (*FS)(nil)
_ fs.StatFS = (*FS)(nil)
- _ fs.StatFS = (*FS)(nil)
_ fs.ReadDirFS = (*FS)(nil)
)
+// An FS holds an SFTP connection and wraps its operations into the
+// [wfs.FS] interface.
type FS struct {
User, Host string
conn *sftp.Client
@@ -46,6 +48,14 @@ var sshAgent = sync.OnceValue(func() agent.ExtendedAgent {
return agent.NewClient(conn)
})
+// sshKeys returns the available ssh public keys. If an ssh agent can be
+// contacted with $SSH_AUTH_SOCK, sshKeys uses the keys from the agent if
+// possible. Otherwise sshKeys loads keys from ~/.ssh. If there are any password
+// protected keys, sshKeys may prompt the user for the password (although it
+// will do so at most once).
+//
+// If a password-protected key is loaded from ~/.ssh, it will be added to the
+// ssh agent if possible.
func sshKeys() ([]ssh.Signer, error) {
sshAgent := sshAgent()
if sshAgent != nil {
@@ -121,6 +131,7 @@ func appendToKnownHosts(hostname string, key ssh.PublicKey) error {
return f.Close()
}
+// Dial establishes a new SFTP connection to the given host.
func Dial(target string) (*FS, error) {
knownHostChecker, err := knownhosts.New(path.Join(os.Getenv("HOME"), ".ssh/known_hosts"))
if err != nil {
@@ -152,6 +163,9 @@ func Dial(target string) (*FS, error) {
if !errors.As(err, &keyErr) || len(keyErr.Want) > 0 {
return err
}
+ // scp prompts the user if the host is not found in
+ // known_hosts, but when is that ever useful? We'll just
+ // add it to known_hosts without bothering the user.
appendToKnownHosts(hostname, key)
return nil
},
@@ -172,6 +186,7 @@ func Dial(target string) (*FS, error) {
}, nil
}
+// Close closes the underlying SFTP connection.
func (f *FS) Close() error {
sftpErr := f.conn.Close()
if err := f.sshConn.Close(); err != nil {
@@ -181,9 +196,13 @@ func (f *FS) Close() error {
}
func (f *FS) err(op, path string, err error) error {
+ // github.com/pkg/sftp's errors are pretty terrible.
+ // We'll wrap them to be more similar to the amazing package os errors.
return fmt.Errorf("%s %q: %w", op, f.User+"@"+f.Host+":"+path, err)
}
+// wfs.FS implementation:
+
func (f *FS) Open(name string) (fs.File, error) {
file, err := f.conn.Open(name)
if err != nil {
diff --git a/internal/wfs/wfs.go b/internal/wfs/wfs.go
index bcdca00..4168b58 100644
--- a/internal/wfs/wfs.go
+++ b/internal/wfs/wfs.go
@@ -1,11 +1,14 @@
+// Package wfs implements a "writable file system" in the spirit of [fs.FS].
package wfs
import (
+ "errors"
"io"
"io/fs"
"path"
)
+// ReadLinkFS is backported from the latest go master.
type ReadLinkFS interface {
fs.FS
@@ -13,6 +16,9 @@ type ReadLinkFS interface {
Lstat(string) (fs.FileInfo, error)
}
+// ReadLink returns the destination of the named symbolic link.
+//
+// If fsys does not implement [ReadLinkFS], then ReadLink returns an error.
func ReadLink(fsys fs.FS, name string) (string, error) {
sym, ok := fsys.(ReadLinkFS)
if !ok {
@@ -21,6 +27,12 @@ func ReadLink(fsys fs.FS, name string) (string, error) {
return sym.ReadLink(name)
}
+// Lstat returns an [fs.FileInfo] describing the named file.
+// If the file is a symbolic link, the returned [fs.FileInfo] describes the
+// symbolic link. Lstat makes no attempt to follow the link.
+//
+// If fsys does not implement [ReadLinkFS], then Lstat is identical
+// to [fs.Stat].
func Lstat(fsys fs.FS, name string) (fs.FileInfo, error) {
sym, ok := fsys.(ReadLinkFS)
if !ok {
@@ -29,6 +41,7 @@ func Lstat(fsys fs.FS, name string) (fs.FileInfo, error) {
return sym.Lstat(name)
}
+// An FS provides access to a writable hierarchical file system.
type FS interface {
fs.FS
@@ -39,12 +52,16 @@ type FS interface {
Chmod(string, fs.FileMode) error
}
+// A MkdirModeFS is a file system with a mkdir method that accepts a file mode.
type MkdirModeFS interface {
FS
MkdirMode(string, fs.FileMode) error
}
+// MkdirMode creates a directory with the given file permission. If fsys
+// implements [MkdirModeFS], MkdirMode calls fsys.MkdirMode. Otherwise,
+// MkdirMode calls Mkdir and then Chmod to set the mode.
func MkdirMode(fsys FS, name string, mode fs.FileMode) error {
if fsys, ok := fsys.(MkdirModeFS); ok {
return fsys.MkdirMode(name, mode)
@@ -56,31 +73,58 @@ func MkdirMode(fsys FS, name string, mode fs.FileMode) error {
}
func removeDir(fsys FS, dir string) error {
- entries, err := fs.ReadDir(fsys, dir)
- if err != nil {
- return err
- }
- for _, f := range entries {
- name := path.Join(dir, f.Name())
- if f.IsDir() {
- err = removeDir(fsys, name)
- } else {
- err = fsys.Remove(name)
- }
- if err != nil {
- return err
+ entries, readErr := fs.ReadDir(fsys, dir)
+ var err error
+ for _, d := range entries {
+ if err1 := removeAll(fsys, path.Join(dir, d.Name()), d); err == nil {
+ err = err1
}
}
- return fsys.Remove(dir)
+ if err == nil {
+ err = readErr
+ }
+ err1 := fsys.Remove(dir)
+ if err1 == nil || errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ if err == nil {
+ err = err1
+ }
+ return err
}
-func RemoveAll(fsys FS, path string) error {
- stat, err := Lstat(fsys, path)
- if err != nil {
+func removeAll(fsys FS, path string, d fs.DirEntry) error {
+ err := fsys.Remove(path)
+ if err == nil || errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ if !d.IsDir() {
return err
}
- if stat.IsDir() {
- return removeDir(fsys, path)
+ return removeDir(fsys, path)
+}
+
+// RemoveAll removes path and any children it contains. It removes everything it
+// can but returns the first error it encounters. If the path does not exist,
+// RemoveAll returns nil (no error).
+func RemoveAll(fsys FS, path string) error {
+ // Simple case: if Remove works, we're done.
+ err := fsys.Remove(path)
+ if err == nil || errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+
+ // Otherwise, is this a directory we need to recurse into?
+ dir, serr := Lstat(fsys, path)
+ if serr != nil {
+ if errors.Is(serr, fs.ErrNotExist) {
+ return nil
+ }
+ return serr
+ }
+ if !dir.IsDir() {
+ // Not a directory; return the error from Remove.
+ return err
}
- return fsys.Remove(path)
+ return removeDir(fsys, path)
}