From cc8efbaf13d57021bee8d4b5bd8a1e46ebe54730 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Fri, 11 Apr 2025 21:26:04 -0700 Subject: Add documentation --- README.md | 12 +++++ ccp.go | 117 ++++++++++++++++++++++++++++++++++-------- ccp.png | Bin 0 -> 23436 bytes go.mod | 2 +- go.sum | 4 +- internal/cp/cp.go | 102 ++++++++++++++++++++++++++---------- internal/wfs/osfs/osfs.go | 2 + internal/wfs/sftpfs/sftpfs.go | 21 +++++++- internal/wfs/wfs.go | 84 ++++++++++++++++++++++-------- 9 files changed, 270 insertions(+), 74 deletions(-) create mode 100644 README.md create mode 100644 ccp.png 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 Binary files /dev/null and b/ccp.png 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) } -- cgit v1.3.1