Improve error messages, esp. when the server is running

* Add file context to minifier errors when publishing
* Misc fixes (see issues)
* Allow custom server error template in layouts/server/error.html

To get to this, this commit also cleans up and simplifies the code surrounding errors and files. This also removes the usage of `github.com/pkg/errors`, mostly because of https://github.com/pkg/errors/issues/223 -- but also because most of this is now built-in to Go.

Fixes #9852
Fixes #9857
Fixes #9863
This commit is contained in:
Bjørn Erik Pedersen
2022-05-02 16:07:52 +02:00
parent 6eea32bd6b
commit f2946da9e8
109 changed files with 861 additions and 780 deletions

View File

@@ -1,4 +1,4 @@
// Copyright 2018 The Hugo Authors. All rights reserved.
// Copyright 2022 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -21,8 +21,6 @@ import (
"strings"
"github.com/gohugoio/hugo/common/text"
"github.com/spf13/afero"
)
// LineMatcher contains the elements used to match an error to a line
@@ -43,8 +41,6 @@ var SimpleLineMatcher = func(m LineMatcher) bool {
return m.Position.LineNumber == m.LineNumber
}
var _ text.Positioner = ErrorContext{}
// ErrorContext contains contextual information about an error. This will
// typically be the lines surrounding some problem in a file.
type ErrorContext struct {
@@ -56,125 +52,11 @@ type ErrorContext struct {
// The position of the error in the Lines above. 0 based.
LinesPos int
position text.Position
// The lexer to use for syntax highlighting.
// https://gohugo.io/content-management/syntax-highlighting/#list-of-chroma-highlighting-languages
ChromaLexer string
}
// Position returns the text position of this error.
func (e ErrorContext) Position() text.Position {
return e.position
}
var _ causer = (*ErrorWithFileContext)(nil)
// ErrorWithFileContext is an error with some additional file context related
// to that error.
type ErrorWithFileContext struct {
cause error
ErrorContext
}
func (e *ErrorWithFileContext) Error() string {
pos := e.Position()
if pos.IsValid() {
return pos.String() + ": " + e.cause.Error()
}
return e.cause.Error()
}
func (e *ErrorWithFileContext) Cause() error {
return e.cause
}
// WithFileContextForFile will try to add a file context with lines matching the given matcher.
// If no match could be found, the original error is returned with false as the second return value.
func WithFileContextForFile(e error, realFilename, filename string, fs afero.Fs, matcher LineMatcherFn) (error, bool) {
f, err := fs.Open(filename)
if err != nil {
return e, false
}
defer f.Close()
return WithFileContext(e, realFilename, f, matcher)
}
// WithFileContextForFileDefault tries to add file context using the default line matcher.
func WithFileContextForFileDefault(err error, filename string, fs afero.Fs) error {
err, _ = WithFileContextForFile(
err,
filename,
filename,
fs,
SimpleLineMatcher)
return err
}
// WithFileContextForFile will try to add a file context with lines matching the given matcher.
// If no match could be found, the original error is returned with false as the second return value.
func WithFileContext(e error, realFilename string, r io.Reader, matcher LineMatcherFn) (error, bool) {
if e == nil {
panic("error missing")
}
le := UnwrapFileError(e)
if le == nil {
var ok bool
if le, ok = ToFileError("", e).(FileError); !ok {
return e, false
}
}
var errCtx ErrorContext
posle := le.Position()
if posle.Offset != -1 {
errCtx = locateError(r, le, func(m LineMatcher) bool {
if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) {
lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber
m.Position = text.Position{LineNumber: lno}
}
return matcher(m)
})
} else {
errCtx = locateError(r, le, matcher)
}
pos := &errCtx.position
if pos.LineNumber == -1 {
return e, false
}
pos.Filename = realFilename
if le.Type() != "" {
errCtx.ChromaLexer = chromaLexerFromType(le.Type())
} else {
errCtx.ChromaLexer = chromaLexerFromFilename(realFilename)
}
return &ErrorWithFileContext{cause: e, ErrorContext: errCtx}, true
}
// UnwrapErrorWithFileContext tries to unwrap an ErrorWithFileContext from err.
// It returns nil if this is not possible.
func UnwrapErrorWithFileContext(err error) *ErrorWithFileContext {
for err != nil {
switch v := err.(type) {
case *ErrorWithFileContext:
return v
case causer:
err = v.Cause()
default:
return nil
}
}
return nil
}
func chromaLexerFromType(fileType string) string {
switch fileType {
case "html", "htm":
@@ -196,23 +78,23 @@ func chromaLexerFromFilename(filename string) string {
return chromaLexerFromType(ext)
}
func locateErrorInString(src string, matcher LineMatcherFn) ErrorContext {
func locateErrorInString(src string, matcher LineMatcherFn) (*ErrorContext, text.Position) {
return locateError(strings.NewReader(src), &fileError{}, matcher)
}
func locateError(r io.Reader, le FileError, matches LineMatcherFn) ErrorContext {
func locateError(r io.Reader, le FileError, matches LineMatcherFn) (*ErrorContext, text.Position) {
if le == nil {
panic("must provide an error")
}
errCtx := ErrorContext{position: text.Position{LineNumber: -1, ColumnNumber: 1, Offset: -1}, LinesPos: -1}
errCtx := &ErrorContext{LinesPos: -1}
pos := text.Position{LineNumber: -1, ColumnNumber: 1, Offset: -1}
b, err := ioutil.ReadAll(r)
if err != nil {
return errCtx
return errCtx, pos
}
pos := &errCtx.position
lepos := le.Position()
lines := strings.Split(string(b), "\n")
@@ -262,5 +144,5 @@ func locateError(r io.Reader, le FileError, matches LineMatcherFn) ErrorContext
}
return errCtx
return errCtx, pos
}

View File

@@ -1,4 +1,4 @@
// Copyright 2018 The Hugo Authors. All rights reserved.
// Copyright 2022 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -38,44 +38,48 @@ LINE 7
LINE 8
`
location := locateErrorInString(lines, lineMatcher)
location, pos := locateErrorInString(lines, lineMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"LINE 3", "LINE 4", "This is THEONE", "LINE 6", "LINE 7"})
pos := location.Position()
c.Assert(pos.LineNumber, qt.Equals, 5)
c.Assert(location.LinesPos, qt.Equals, 2)
c.Assert(locateErrorInString(`This is THEONE`, lineMatcher).Lines, qt.DeepEquals, []string{"This is THEONE"})
locate := func(s string, m LineMatcherFn) *ErrorContext {
ctx, _ := locateErrorInString(s, m)
return ctx
}
location = locateErrorInString(`L1
c.Assert(locate(`This is THEONE`, lineMatcher).Lines, qt.DeepEquals, []string{"This is THEONE"})
location, pos = locateErrorInString(`L1
This is THEONE
L2
`, lineMatcher)
c.Assert(location.Position().LineNumber, qt.Equals, 2)
c.Assert(pos.LineNumber, qt.Equals, 2)
c.Assert(location.LinesPos, qt.Equals, 1)
c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "This is THEONE", "L2", ""})
location = locateErrorInString(`This is THEONE
location = locate(`This is THEONE
L2
`, lineMatcher)
c.Assert(location.LinesPos, qt.Equals, 0)
c.Assert(location.Lines, qt.DeepEquals, []string{"This is THEONE", "L2", ""})
location = locateErrorInString(`L1
location = locate(`L1
This THEONE
`, lineMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "This THEONE", ""})
c.Assert(location.LinesPos, qt.Equals, 1)
location = locateErrorInString(`L1
location = locate(`L1
L2
This THEONE
`, lineMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"L1", "L2", "This THEONE", ""})
c.Assert(location.LinesPos, qt.Equals, 2)
location = locateErrorInString("NO MATCH", lineMatcher)
c.Assert(location.Position().LineNumber, qt.Equals, -1)
location, pos = locateErrorInString("NO MATCH", lineMatcher)
c.Assert(pos.LineNumber, qt.Equals, -1)
c.Assert(location.LinesPos, qt.Equals, -1)
c.Assert(len(location.Lines), qt.Equals, 0)
@@ -83,7 +87,7 @@ This THEONE
return m.LineNumber == 6
}
location = locateErrorInString(`A
location, pos = locateErrorInString(`A
B
C
D
@@ -95,7 +99,7 @@ I
J`, lineMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"D", "E", "F", "G", "H"})
c.Assert(location.Position().LineNumber, qt.Equals, 6)
c.Assert(pos.LineNumber, qt.Equals, 6)
c.Assert(location.LinesPos, qt.Equals, 2)
// Test match EOF
@@ -103,26 +107,26 @@ J`, lineMatcher)
return m.LineNumber == 4
}
location = locateErrorInString(`A
location, pos = locateErrorInString(`A
B
C
`, lineMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"B", "C", ""})
c.Assert(location.Position().LineNumber, qt.Equals, 4)
c.Assert(pos.LineNumber, qt.Equals, 4)
c.Assert(location.LinesPos, qt.Equals, 2)
offsetMatcher := func(m LineMatcher) bool {
return m.Offset == 1
}
location = locateErrorInString(`A
location, pos = locateErrorInString(`A
B
C
D
E`, offsetMatcher)
c.Assert(location.Lines, qt.DeepEquals, []string{"A", "B", "C", "D"})
c.Assert(location.Position().LineNumber, qt.Equals, 2)
c.Assert(pos.LineNumber, qt.Equals, 2)
c.Assert(location.LinesPos, qt.Equals, 1)
}

View File

@@ -19,37 +19,11 @@ import (
"errors"
"fmt"
"io"
"os"
"runtime"
"runtime/debug"
"strconv"
_errors "github.com/pkg/errors"
)
// As defined in https://godoc.org/github.com/pkg/errors
type causer interface {
Cause() error
}
type stackTracer interface {
StackTrace() _errors.StackTrace
}
// PrintStackTraceFromErr prints the error's stack trace to stdoud.
func PrintStackTraceFromErr(err error) {
FprintStackTraceFromErr(os.Stdout, err)
}
// FprintStackTraceFromErr prints the error's stack trace to w.
func FprintStackTraceFromErr(w io.Writer, err error) {
if err, ok := err.(stackTracer); ok {
for _, f := range err.StackTrace() {
fmt.Fprintf(w, "%+s:%d\n", f, f)
}
}
}
// PrintStackTrace prints the current stacktrace to w.
func PrintStackTrace(w io.Writer) {
buf := make([]byte, 1<<16)

View File

@@ -1,11 +1,11 @@
// Copyright 2018 The Hugo Authors. All rights reserved.
// Copyright 2022 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// Unless required by applicable lfmtaw or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
@@ -15,59 +15,217 @@ package herrors
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/common/text"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/afero"
"github.com/tdewolff/parse/v2"
"github.com/pkg/errors"
"errors"
)
var _ causer = (*fileError)(nil)
// FileError represents an error when handling a file: Parsing a config file,
// execute a template etc.
type FileError interface {
error
// ErroContext holds some context information about the error.
ErrorContext() *ErrorContext
text.Positioner
// A string identifying the type of file, e.g. JSON, TOML, markdown etc.
Type() string
// UpdatePosition updates the position of the error.
UpdatePosition(pos text.Position) FileError
// UpdateContent updates the error with a new ErrorContext from the content of the file.
UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError
}
var _ FileError = (*fileError)(nil)
// Unwrapper can unwrap errors created with fmt.Errorf.
type Unwrapper interface {
Unwrap() error
}
var (
_ FileError = (*fileError)(nil)
_ Unwrapper = (*fileError)(nil)
)
func (fe *fileError) UpdatePosition(pos text.Position) FileError {
oldFilename := fe.Position().Filename
if pos.Filename != "" && fe.fileType == "" {
_, fe.fileType = paths.FileAndExtNoDelimiter(filepath.Clean(pos.Filename))
}
if pos.Filename == "" {
pos.Filename = oldFilename
}
fe.position = pos
return fe
}
func (fe *fileError) UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError {
if linematcher == nil {
linematcher = SimpleLineMatcher
}
var (
contentPos text.Position
posle = fe.position
errorContext *ErrorContext
)
if posle.LineNumber <= 1 && posle.Offset > 0 {
// Try to locate the line number from the content if offset is set.
errorContext, contentPos = locateError(r, fe, func(m LineMatcher) bool {
if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) {
lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber
m.Position = text.Position{LineNumber: lno}
return linematcher(m)
}
return false
})
} else {
errorContext, contentPos = locateError(r, fe, linematcher)
}
if errorContext.ChromaLexer == "" {
if fe.fileType != "" {
errorContext.ChromaLexer = chromaLexerFromType(fe.fileType)
} else {
errorContext.ChromaLexer = chromaLexerFromFilename(fe.Position().Filename)
}
}
fe.errorContext = errorContext
if contentPos.LineNumber > 0 {
fe.position.LineNumber = contentPos.LineNumber
}
return fe
}
type fileError struct {
position text.Position
position text.Position
errorContext *ErrorContext
fileType string
cause error
}
type fileErrorWithErrorContext struct {
*fileError
}
func (e *fileError) ErrorContext() *ErrorContext {
return e.errorContext
}
// Position returns the text position of this error.
func (e fileError) Position() text.Position {
return e.position
}
func (e *fileError) Type() string {
return e.fileType
}
func (e *fileError) Error() string {
if e.cause == nil {
return ""
return fmt.Sprintf("%s: %s", e.position, e.cause)
}
func (e *fileError) Unwrap() error {
return e.cause
}
// NewFileError creates a new FileError that wraps err.
// The value for name should identify the file, the best
// being the full filename to the file on disk.
func NewFileError(name string, err error) FileError {
if err == nil {
panic("err is nil")
}
return e.cause.Error()
}
func (f *fileError) Cause() error {
return f.cause
}
// Filetype is used to determine the Chroma lexer to use.
fileType, pos := extractFileTypePos(err)
pos.Filename = name
if fileType == "" {
_, fileType = paths.FileAndExtNoDelimiter(filepath.Clean(name))
}
if pos.LineNumber < 0 {
panic(fmt.Sprintf("invalid line number: %d", pos.LineNumber))
}
// NewFileError creates a new FileError.
func NewFileError(fileType string, offset, lineNumber, columnNumber int, err error) FileError {
pos := text.Position{Offset: offset, LineNumber: lineNumber, ColumnNumber: columnNumber}
return &fileError{cause: err, fileType: fileType, position: pos}
}
// NewFileErrorFromFile is a convenience method to create a new FileError from a file.
func NewFileErrorFromFile(err error, filename, realFilename string, fs afero.Fs, linematcher LineMatcherFn) FileError {
if err == nil {
panic("err is nil")
}
if linematcher == nil {
linematcher = SimpleLineMatcher
}
f, err2 := fs.Open(filename)
if err2 != nil {
return NewFileError(realFilename, err)
}
defer f.Close()
return NewFileError(realFilename, err).UpdateContent(f, linematcher)
}
// Cause returns the underlying error or itself if it does not implement Unwrap.
func Cause(err error) error {
if u := errors.Unwrap(err); u != nil {
return u
}
return err
}
func extractFileTypePos(err error) (string, text.Position) {
err = Cause(err)
var fileType string
// Fall back to line/col 1:1 if we cannot find any better information.
pos := text.Position{
Offset: -1,
LineNumber: 1,
ColumnNumber: 1,
}
// JSON errors.
offset, typ := extractOffsetAndType(err)
if fileType == "" {
fileType = typ
}
if offset >= 0 {
pos.Offset = offset
}
// The error type from the minifier contains line number and column number.
if line, col := exctractLineNumberAndColumnNumber(err); line >= 0 {
pos.LineNumber = line
pos.ColumnNumber = col
return fileType, pos
}
// Look in the error message for the line number.
for _, handle := range lineNumberExtractors {
lno, col := handle(err)
if lno > 0 {
pos.ColumnNumber = col
pos.LineNumber = lno
break
}
}
return fileType, pos
}
// UnwrapFileError tries to unwrap a FileError from err.
@@ -77,49 +235,26 @@ func UnwrapFileError(err error) FileError {
switch v := err.(type) {
case FileError:
return v
case causer:
err = v.Cause()
default:
return nil
err = errors.Unwrap(err)
}
}
return nil
}
// ToFileErrorWithOffset will return a new FileError with a line number
// with the given offset from the original.
func ToFileErrorWithOffset(fe FileError, offset int) FileError {
pos := fe.Position()
return ToFileErrorWithLineNumber(fe, pos.LineNumber+offset)
}
// ToFileErrorWithOffset will return a new FileError with the given line number.
func ToFileErrorWithLineNumber(fe FileError, lineNumber int) FileError {
pos := fe.Position()
pos.LineNumber = lineNumber
return &fileError{cause: fe, fileType: fe.Type(), position: pos}
}
// ToFileError will convert the given error to an error supporting
// the FileError interface.
func ToFileError(fileType string, err error) FileError {
for _, handle := range lineNumberExtractors {
lno, col := handle(err)
offset, typ := extractOffsetAndType(err)
if fileType == "" {
fileType = typ
}
if lno > 0 || offset != -1 {
return NewFileError(fileType, offset, lno, col, err)
// UnwrapFileErrorsWithErrorContext tries to unwrap all FileError in err that has an ErrorContext.
func UnwrapFileErrorsWithErrorContext(err error) []FileError {
var errs []FileError
for err != nil {
if v, ok := err.(FileError); ok && v.ErrorContext() != nil {
errs = append(errs, v)
}
err = errors.Unwrap(err)
}
// Fall back to the pointing to line number 1.
return NewFileError(fileType, -1, 1, 1, err)
return errs
}
func extractOffsetAndType(e error) (int, string) {
e = errors.Cause(e)
switch v := e.(type) {
case *json.UnmarshalTypeError:
return int(v.Offset), "json"
@@ -129,3 +264,15 @@ func extractOffsetAndType(e error) (int, string) {
return -1, ""
}
}
func exctractLineNumberAndColumnNumber(e error) (int, int) {
switch v := e.(type) {
case *parse.Error:
return v.Line, v.Column
case *toml.DecodeError:
return v.Position()
}
return -1, -1
}

View File

@@ -1,4 +1,4 @@
// Copyright 2018 The Hugo Authors. All rights reserved.
// Copyright 2022 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,14 +14,44 @@
package herrors
import (
"fmt"
"strings"
"testing"
"github.com/pkg/errors"
"errors"
"github.com/gohugoio/hugo/common/text"
qt "github.com/frankban/quicktest"
)
func TestToLineNumberError(t *testing.T) {
func TestNewFileError(t *testing.T) {
t.Parallel()
c := qt.New(t)
fe := NewFileError("foo.html", errors.New("bar"))
c.Assert(fe.Error(), qt.Equals, `"foo.html:1:1": bar`)
lines := ""
for i := 1; i <= 100; i++ {
lines += fmt.Sprintf("line %d\n", i)
}
fe.UpdatePosition(text.Position{LineNumber: 32, ColumnNumber: 2})
c.Assert(fe.Error(), qt.Equals, `"foo.html:32:2": bar`)
fe.UpdatePosition(text.Position{LineNumber: 0, ColumnNumber: 0, Offset: 212})
fe.UpdateContent(strings.NewReader(lines), SimpleLineMatcher)
c.Assert(fe.Error(), qt.Equals, `"foo.html:32:0": bar`)
errorContext := fe.ErrorContext()
c.Assert(errorContext, qt.IsNotNil)
c.Assert(errorContext.Lines, qt.DeepEquals, []string{"line 30", "line 31", "line 32", "line 33", "line 34"})
c.Assert(errorContext.LinesPos, qt.Equals, 2)
c.Assert(errorContext.ChromaLexer, qt.Equals, "go-html-template")
}
func TestNewFileErrorExtractFromMessage(t *testing.T) {
t.Parallel()
c := qt.New(t)
@@ -37,18 +67,16 @@ func TestToLineNumberError(t *testing.T) {
{errors.New("parse failed: template: _default/bundle-resource-meta.html:11: unexpected in operand"), 0, 11, 1},
{errors.New(`failed:: template: _default/bundle-resource-meta.html:2:7: executing "main" at <.Titles>`), 0, 2, 7},
{errors.New(`failed to load translations: (6, 7): was expecting token =, but got "g" instead`), 0, 6, 7},
{errors.New(`execute of template failed: template: index.html:2:5: executing "index.html" at <partial "foo.html" .>: error calling partial: "/layouts/partials/foo.html:3:6": execute of template failed: template: partials/foo.html:3:6: executing "partials/foo.html" at <.ThisDoesNotExist>: can't evaluate field ThisDoesNotExist in type *hugolib.pageStat`), 0, 2, 5},
} {
got := ToFileError("template", test.in)
got := NewFileError("test.txt", test.in)
errMsg := qt.Commentf("[%d][%T]", i, got)
le, ok := got.(FileError)
c.Assert(ok, qt.Equals, true)
c.Assert(ok, qt.Equals, true, errMsg)
pos := le.Position()
pos := got.Position()
c.Assert(pos.LineNumber, qt.Equals, test.lineNumber, errMsg)
c.Assert(pos.ColumnNumber, qt.Equals, test.columnNumber, errMsg)
c.Assert(errors.Cause(got), qt.Not(qt.IsNil))
c.Assert(errors.Unwrap(got), qt.Not(qt.IsNil))
}
}

View File

@@ -16,36 +16,22 @@ package herrors
import (
"regexp"
"strconv"
"github.com/pkg/errors"
"github.com/pelletier/go-toml/v2"
)
var lineNumberExtractors = []lineNumberExtractor{
// Template/shortcode parse errors
newLineNumberErrHandlerFromRegexp(".*:(\\d+):(\\d*):"),
newLineNumberErrHandlerFromRegexp(".*:(\\d+):"),
newLineNumberErrHandlerFromRegexp(`:(\d+):(\d*):`),
newLineNumberErrHandlerFromRegexp(`:(\d+):`),
// TOML parse errors
tomlLineNumberExtractor,
// YAML parse errors
newLineNumberErrHandlerFromRegexp("line (\\d+):"),
newLineNumberErrHandlerFromRegexp(`line (\d+):`),
// i18n bundle errors
newLineNumberErrHandlerFromRegexp("\\((\\d+),\\s(\\d*)"),
newLineNumberErrHandlerFromRegexp(`\((\d+),\s(\d*)`),
}
type lineNumberExtractor func(e error) (int, int)
var tomlLineNumberExtractor = func(e error) (int, int) {
e = errors.Cause(e)
if terr, ok := e.(*toml.DecodeError); ok {
return terr.Position()
}
return -1, -1
}
func newLineNumberErrHandlerFromRegexp(expression string) lineNumberExtractor {
re := regexp.MustCompile(expression)
return extractLineNo(re)
@@ -72,6 +58,6 @@ func extractLineNo(re *regexp.Regexp) lineNumberExtractor {
return lno, col
}
return -1, col
return 0, col
}
}

View File

@@ -14,13 +14,12 @@
package hugio
import (
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/spf13/afero"
)
@@ -60,7 +59,7 @@ func CopyDir(fs afero.Fs, from, to string, shouldCopy func(filename string) bool
}
if !fi.IsDir() {
return errors.Errorf("%q is not a directory", from)
return fmt.Errorf("%q is not a directory", from)
}
err = fs.MkdirAll(to, 0777) // before umask