Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add checksum for "generates" (proof of concept) #1816

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ tasks:
generates:
- "internal/mocks/*.go"
cmds:
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name SourcesCheckable"
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name StatusCheckable"
- "GODEBUG=gotypesalias=0 {{.BIN}}/mockery --dir ./internal/fingerprint --name SourcesCheckable"
- "GODEBUG=gotypesalias=0 {{.BIN}}/mockery --dir ./internal/fingerprint --name StatusCheckable"

install:mockery:
desc: Installs mockgen; a tool to generate mock files
vars:
MOCKERY_VERSION: v2.24.0
MOCKERY_VERSION: v2.46.0
env:
GOBIN: "{{.BIN}}"
status:
Expand Down
2 changes: 1 addition & 1 deletion help.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool) (*editors.Ta
if tasks[i].Method != "" {
method = tasks[i].Method
}
upToDate, err := fingerprint.IsTaskUpToDate(context.Background(), tasks[i],
upToDate, _, err := fingerprint.IsTaskUpToDate(context.Background(), tasks[i],
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir.Fingerprint),
fingerprint.WithDry(e.Dry),
Expand Down
3 changes: 2 additions & 1 deletion internal/fingerprint/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type StatusCheckable interface {

// SourcesCheckable defines any type that can check if the sources of a task are up-to-date.
type SourcesCheckable interface {
IsUpToDate(t *ast.Task) (bool, error)
SetUpToDate(t *ast.Task, sourceState string) error
IsUpToDate(t *ast.Task) (upToDate bool, sourceState string, err error)
Value(t *ast.Task) (any, error)
OnError(t *ast.Task) error
Kind() string
Expand Down
117 changes: 75 additions & 42 deletions internal/fingerprint/sources_checksum.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,52 +28,73 @@ func NewChecksumChecker(tempDir string, dry bool) *ChecksumChecker {
}
}

func (checker *ChecksumChecker) IsUpToDate(t *ast.Task) (bool, error) {
if len(t.Sources) == 0 {
return false, nil
func (checker *ChecksumChecker) IsUpToDate(t *ast.Task) (bool, string, error) {
if len(t.Sources) == 0 && len(t.Generates) == 0 {
return false, "", nil
}

checksumFile := checker.checksumFilePath(t)

data, _ := os.ReadFile(checksumFile)
oldHash := strings.TrimSpace(string(data))
oldHashes := strings.TrimSpace(string(data))
oldSourcesHash, oldGeneratesdHash, _ := strings.Cut(oldHashes, "\n")

newHash, err := checker.checksum(t)
newSourcesHash, err := checker.checksum(t, t.Sources)
if err != nil {
return false, nil
return false, "", err
}

if !checker.dry && oldHash != newHash {
_ = os.MkdirAll(filepathext.SmartJoin(checker.tempDir, "checksum"), 0o755)
if err = os.WriteFile(checksumFile, []byte(newHash+"\n"), 0o644); err != nil {
return false, err
}
newGeneratesHash, err := checker.checksum(t, t.Generates)
if err != nil {
return false, "", err
}

if len(t.Generates) > 0 {
// For each specified 'generates' field, check whether the files actually exist
for _, g := range t.Generates {
if g.Negate {
continue
}
generates, err := Glob(t.Dir, g.Glob)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, err
}
if len(generates) == 0 {
return false, nil
}
}
return oldSourcesHash == newSourcesHash && oldGeneratesdHash == newGeneratesHash, newSourcesHash, nil
}

func (checker *ChecksumChecker) SetUpToDate(t *ast.Task, sourceHash string) error {
if len(t.Sources) == 0 && len(t.Generates) == 0 {
return nil
}

if checker.dry {
return nil
}

newSourcesHash, err := checker.checksum(t, t.Sources)
if err != nil {
return err
}

checksumFile := checker.checksumFilePath(t)

if sourceHash != "" && newSourcesHash != sourceHash {
// sources have changed since the task was executed, remove the checksum file
// since the next execution will have a different checksum
os.Remove(checksumFile)
return nil
}

newGeneratesHash, err := checker.checksum(t, t.Generates)
if err != nil {
return err
}

return oldHash == newHash, nil
_ = os.MkdirAll(filepathext.SmartJoin(checker.tempDir, "checksum"), 0o755)
if err = os.WriteFile(checksumFile, []byte(newSourcesHash+"\n"+newGeneratesHash+"\n"), 0o644); err != nil {
return err
}

return nil
}

func (checker *ChecksumChecker) Value(t *ast.Task) (any, error) {
return checker.checksum(t)
c1, err := checker.checksum(t, t.Sources)
if err != nil {
return c1, err
}
c2, err := checker.checksum(t, t.Generates)
return c1 + "\n" + c2, err
}

func (checker *ChecksumChecker) OnError(t *ast.Task) error {
Expand All @@ -87,8 +108,8 @@ func (*ChecksumChecker) Kind() string {
return "checksum"
}

func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) {
sources, err := Globs(t.Dir, t.Sources)
func (c *ChecksumChecker) checksum(t *ast.Task, globs []*ast.Glob) (string, error) {
sources, err := Globs(t.Dir, globs)
if err != nil {
return "", err
}
Expand All @@ -97,19 +118,31 @@ func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) {
buf := make([]byte, 128*1024)
for _, f := range sources {
// also sum the filename, so checksum changes for renaming a file
if _, err := io.CopyBuffer(h, strings.NewReader(filepath.Base(f)), buf); err != nil {
return "", err
}
f, err := os.Open(f)
if err != nil {
return "", err
if rel, err := filepath.Rel(t.Dir, f); err == nil {
h.WriteString(rel)
} else {
// couldn't make a relative path, use the full path to be safe
h.WriteString(f)
}
if _, err = io.CopyBuffer(h, f, buf); err != nil {
return "", err
// if we have a symlink here: we hash the link and *not* the target content
if fi, err := os.Stat(f); err == nil && fi.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(f)
if err != nil {
return "", err
}
h.WriteString(link)
} else {
f, err := os.Open(f)
if err != nil {
return "", err
}
_, err = io.CopyBuffer(h, f, buf)
f.Close()
if err != nil {
return "", err
}
}
f.Close()
}

hash := h.Sum128()
return fmt.Sprintf("%x%x", hash.Hi, hash.Lo), nil
}
Expand Down
8 changes: 6 additions & 2 deletions internal/fingerprint/sources_none.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import "github.com/go-task/task/v3/taskfile/ast"
// It will always report that the task is not up-to-date.
type NoneChecker struct{}

func (NoneChecker) IsUpToDate(t *ast.Task) (bool, error) {
return false, nil
func (NoneChecker) IsUpToDate(t *ast.Task) (bool, string, error) {
return false, "", nil
}

func (NoneChecker) SetUpToDate(t *ast.Task, sourceState string) error {
return nil
}

func (NoneChecker) Value(t *ast.Task) (any, error) {
Expand Down
24 changes: 14 additions & 10 deletions internal/fingerprint/sources_timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ func NewTimestampChecker(tempDir string, dry bool) *TimestampChecker {
}

// IsUpToDate implements the Checker interface
func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, string, error) {
if len(t.Sources) == 0 {
return false, nil
return false, "", nil
}

sources, err := Globs(t.Dir, t.Sources)
if err != nil {
return false, nil
return false, "", nil
}
generates, err := Globs(t.Dir, t.Generates)
if err != nil {
return false, nil
return false, "", nil
}

timestampFile := checker.timestampFilePath(t)
Expand All @@ -48,11 +48,11 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
// Create the timestamp file for the next execution when the file does not exist.
if !checker.dry {
if err := os.MkdirAll(filepath.Dir(timestampFile), 0o755); err != nil {
return false, err
return false, "", err
}
f, err := os.Create(timestampFile)
if err != nil {
return false, err
return false, "", err
}
f.Close()
}
Expand All @@ -65,23 +65,27 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
// Get the max time of the generates.
generateMaxTime, err := getMaxTime(generates...)
if err != nil || generateMaxTime.IsZero() {
return false, nil
return false, "", nil
}

// Check if any of the source files is newer than the max time of the generates.
shouldUpdate, err := anyFileNewerThan(sources, generateMaxTime)
if err != nil {
return false, nil
return false, "", nil
}

// Modify the metadata of the file to the the current time.
if !checker.dry {
if err := os.Chtimes(timestampFile, taskTime, taskTime); err != nil {
return false, err
return false, "", err
}
}

return !shouldUpdate, nil
return !shouldUpdate, "", nil
}

func (checker *TimestampChecker) SetUpToDate(t *ast.Task, sourceState string) error {
return nil // TODO: implement
}

func (checker *TimestampChecker) Kind() string {
Expand Down
60 changes: 50 additions & 10 deletions internal/fingerprint/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func IsTaskUpToDate(
ctx context.Context,
t *ast.Task,
opts ...CheckerOption,
) (bool, error) {
) (bool, string, error) {
var statusUpToDate bool
var sourcesUpToDate bool
var err error
Expand Down Expand Up @@ -88,45 +88,85 @@ func IsTaskUpToDate(
if config.sourcesChecker == nil {
config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry)
if err != nil {
return false, err
return false, "", err
}
}

statusIsSet := len(t.Status) != 0
sourcesIsSet := len(t.Sources) != 0
sourcesIsSet := len(t.Sources) != 0 || len(t.Generates) != 0

// If status is set, check if it is up-to-date
if statusIsSet {
statusUpToDate, err = config.statusChecker.IsUpToDate(ctx, t)
if err != nil {
return false, err
return false, "", err
}
}

sourcesState := ""
// If sources is set, check if they are up-to-date
if sourcesIsSet {
sourcesUpToDate, err = config.sourcesChecker.IsUpToDate(t)
sourcesUpToDate, sourcesState, err = config.sourcesChecker.IsUpToDate(t)
if err != nil {
return false, err
return false, "", err
}
}

// If both status and sources are set, the task is up-to-date if both are up-to-date
if statusIsSet && sourcesIsSet {
return statusUpToDate && sourcesUpToDate, nil
return statusUpToDate && sourcesUpToDate, sourcesState, nil
}

// If only status is set, the task is up-to-date if the status is up-to-date
if statusIsSet {
return statusUpToDate, nil
return statusUpToDate, sourcesState, nil
}

// If only sources is set, the task is up-to-date if the sources are up-to-date
if sourcesIsSet {
return sourcesUpToDate, nil
return sourcesUpToDate, sourcesState, nil
}

// If no status or sources are set, the task should always run
// i.e. it is never considered "up-to-date"
return false, nil
return false, sourcesState, nil
}

func SetTaskUpToDate(
ctx context.Context,
t *ast.Task,
sourceState string,
opts ...CheckerOption,
) error {
var err error

// Default config
config := &CheckerConfig{
method: "none",
tempDir: "",
dry: false,
logger: nil,
statusChecker: nil,
sourcesChecker: nil,
}

// Apply functional options
for _, opt := range opts {
opt(config)
}

// If no status checker was given, set up the default one
if config.statusChecker == nil {
config.statusChecker = NewStatusChecker(config.logger)
}

// If no sources checker was given, set up the default one
if config.sourcesChecker == nil {
config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry)
if err != nil {
return err
}
}

return config.sourcesChecker.SetUpToDate(t, sourceState)
}
Loading