diff --git a/build.sh b/build.sh index edc3d00..6af9e73 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,14 @@ #!/bin/bash -set -e +set -euo pipefail dep ensure -rm vcs_mocks/*.go || true && rm fs_mocks/*.go || true \ - && go get github.com/vektra/mockery/.../ \ + +rm helper_mocks/*.go || true \ + && rm vcs_mocks/*.go || true \ + && rm fs_mocks/*.go || true \ + && rm core_mocks/*.go || true + +go get github.com/vektra/mockery/.../ \ && mockery -output helper_mocks -outpkg helper_mocks -dir helper -name Clock \ && mockery -output vcs_mocks -outpkg vcs_mocks -dir vcs -name Vcs \ && mockery -output vcs_mocks -outpkg vcs_mocks -dir vcs -name VersioningClient \ @@ -11,7 +16,8 @@ rm vcs_mocks/*.go || true && rm fs_mocks/*.go || true \ && mockery -output fs_mocks -outpkg fs_mocks -dir fs -name FileReader \ && mockery -output fs_mocks -outpkg fs_mocks -dir fs -name File \ && mockery -output fs_mocks -outpkg fs_mocks -dir fs -name PathMatcher \ - && mockery -output fs_mocks -outpkg fs_mocks -dir fs -name ExecutionTracker + && mockery -output core_mocks -outpkg core_mocks -dir core -name ExecutionTracker + go build ./... go clean -testcache && go test -v ./... rm headache 2> /dev/null || true && go build diff --git a/core/configuration.go b/core/configuration.go index ed90558..bf4df8e 100644 --- a/core/configuration.go +++ b/core/configuration.go @@ -23,10 +23,10 @@ import ( "regexp" ) -func DefaultSystemConfiguration() SystemConfiguration { - return SystemConfiguration{ +func DefaultSystemConfiguration() *SystemConfiguration { + return &SystemConfiguration{ VersioningClient: &vcs.Client{ - Vcs: vcs.Git{}, + Vcs: &vcs.Git{}, }, FileSystem: fs.DefaultFileSystem(), Clock: helper.SystemClock{}, @@ -35,7 +35,7 @@ func DefaultSystemConfiguration() SystemConfiguration { type SystemConfiguration struct { VersioningClient vcs.VersioningClient - FileSystem fs.FileSystem + FileSystem *fs.FileSystem Clock helper.Clock } @@ -54,25 +54,22 @@ type ChangeSet struct { } func ParseConfiguration( - config Configuration, - sysConfig SystemConfiguration, - tracker fs.ExecutionTracker, + currentConfig *Configuration, + system *SystemConfiguration, + tracker ExecutionTracker, pathMatcher fs.PathMatcher) (*ChangeSet, error) { - // TODO: config.HeaderFile may have changed since last run - this may cause e.g. to not find the previous header file! - headerContents, err := tracker.ReadLinesAtLastExecutionRevision(config.HeaderFile) + versionedTemplate, err := tracker.RetrieveVersionedTemplate(currentConfig) if err != nil { return nil, err } - // TODO: config.TemplateData may have changed since last run -- this may change the detection regex! - // note: comment style does not matter as the detection regex is designed to be insensitive to the style in use - contents, err := ParseTemplate(headerContents, config.TemplateData, ParseCommentStyle(config.CommentStyle)) + contents, err := ParseTemplate(versionedTemplate, ParseCommentStyle(currentConfig.CommentStyle)) if err != nil { return nil, err } - changes, err := getFileChanges(config, sysConfig, tracker, pathMatcher) + changes, err := getAffectedFiles(currentConfig, system, versionedTemplate, pathMatcher) if err != nil { return nil, err } @@ -84,26 +81,25 @@ func ParseConfiguration( }, nil } -func getFileChanges(config Configuration, - sysConfig SystemConfiguration, - tracker fs.ExecutionTracker, +func getAffectedFiles(config *Configuration, + sysConfig *SystemConfiguration, + versionedTemplate *VersionedHeaderTemplate, pathMatcher fs.PathMatcher) ([]vcs.FileChange, error) { versioningClient := sysConfig.VersioningClient fileSystem := sysConfig.FileSystem - revision, err := tracker.GetLastExecutionRevision() - if err != nil { - return nil, err - } - var changes []vcs.FileChange - // TODO: centralize this check between here and ExecutionTracker#ReadLinesAtLastExecutionRevision - if revision == "" { + var ( + changes []vcs.FileChange + err error + ) + + if versionedTemplate.RequiresFullScan() { changes, err = pathMatcher.ScanAllFiles(config.Includes, config.Excludes, fileSystem) if err != nil { return nil, err } } else { - fileChanges, err := versioningClient.GetChanges(revision) + fileChanges, err := versioningClient.GetChanges(versionedTemplate.Revision) if err != nil { return nil, err } diff --git a/core/configuration_loader.go b/core/configuration_loader.go new file mode 100644 index 0000000..32eb9f4 --- /dev/null +++ b/core/configuration_loader.go @@ -0,0 +1,59 @@ +package core + +import ( + "encoding/json" + "github.com/fbiville/headache/fs" + jsonsch "github.com/xeipuuv/gojsonschema" + "log" +) + +type ConfigurationLoader struct { + Reader fs.FileReader +} + +func (cl *ConfigurationLoader) ReadConfiguration(configFile *string) (*Configuration, error) { + err := cl.validateConfiguration(configFile) + if err != nil { + return nil, err + } + + payload, err := cl.Reader.Read(*configFile) + if err != nil { + return nil, err + } + configuration, err := cl.UnmarshallConfiguration(payload) + if err != nil { + return nil, err + } + return configuration, err +} + +func (cl *ConfigurationLoader) UnmarshallConfiguration(configurationPayload []byte) (*Configuration, error) { + result := Configuration{} + err := json.Unmarshal(configurationPayload, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func (cl *ConfigurationLoader) validateConfiguration(configFile *string) error { + schema := loadSchema() + if schema == nil { + return nil + } + jsonSchemaValidator := JsonSchemaValidator{ + Schema: schema, + FileReader: cl.Reader, + } + return jsonSchemaValidator.Validate("file://" + *configFile) +} + +func loadSchema() *jsonsch.Schema { + schema, err := jsonsch.NewSchema(jsonsch.NewReferenceLoader("https://fbiville.github.io/headache/v1.0.0-M01/schema.json")) + if err != nil { + log.Printf("headache configuration warning: cannot load schema, skipping configuration validation. See reason below:\n\t%v\n", err) + return nil + } + return schema +} diff --git a/core/configuration_test.go b/core/configuration_test.go index 28c49c7..6d35a7f 100644 --- a/core/configuration_test.go +++ b/core/configuration_test.go @@ -18,6 +18,7 @@ package core_test import ( "github.com/fbiville/headache/core" + "github.com/fbiville/headache/core_mocks" "github.com/fbiville/headache/fs" "github.com/fbiville/headache/fs_mocks" "github.com/fbiville/headache/helper_mocks" @@ -33,36 +34,42 @@ var _ = Describe("Configuration parser", func() { t GinkgoTInterface fileReader *fs_mocks.FileReader fileWriter *fs_mocks.FileWriter - fileSystem fs.FileSystem + fileSystem *fs.FileSystem versioningClient *vcs_mocks.VersioningClient - tracker *fs_mocks.ExecutionTracker + tracker *core_mocks.ExecutionTracker pathMatcher *fs_mocks.PathMatcher clock *helper_mocks.Clock initialChanges []FileChange includes []string excludes []string resultingChanges []FileChange - systemConfiguration core.SystemConfiguration + systemConfiguration *core.SystemConfiguration + data map[string]string + revision string ) BeforeEach(func() { t = GinkgoT() fileReader = new(fs_mocks.FileReader) fileWriter = new(fs_mocks.FileWriter) - fileSystem = fs.FileSystem{FileWriter: fileWriter, FileReader: fileReader} + fileSystem = &fs.FileSystem{FileWriter: fileWriter, FileReader: fileReader} versioningClient = new(vcs_mocks.VersioningClient) - tracker = new(fs_mocks.ExecutionTracker) + tracker = new(core_mocks.ExecutionTracker) pathMatcher = new(fs_mocks.PathMatcher) clock = new(helper_mocks.Clock) initialChanges = []FileChange{{Path: "hello-world.go"}, {Path: "license.txt"}} includes = []string{"../fixtures/hello_*.go"} excludes = []string{} resultingChanges = []FileChange{initialChanges[0]} - systemConfiguration = core.SystemConfiguration{ + systemConfiguration = &core.SystemConfiguration{ FileSystem: fileSystem, Clock: clock, VersioningClient: versioningClient, } + data = map[string]string{ + "Owner": "ACME Labs", + } + revision = "some-sha" }) AfterEach(func() { @@ -75,21 +82,18 @@ var _ = Describe("Configuration parser", func() { }) It("pre-computes the final configuration", func() { - tracker.On("ReadLinesAtLastExecutionRevision", "some-header"). - Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}\n\nSome fictional license"), nil) - tracker.On("GetLastExecutionRevision").Return("some-sha", nil) - versioningClient.On("GetChanges", "some-sha").Return(initialChanges, nil) - pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) - versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) - - configuration := core.Configuration{ + configuration := &core.Configuration{ HeaderFile: "some-header", CommentStyle: "SlashSlash", Includes: includes, Excludes: excludes, - TemplateData: map[string]string{ - "Owner": "ACME Labs", - }} + TemplateData: data, + } + tracker.On("RetrieveVersionedTemplate", configuration). + Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}\n\nSome fictional license", data, revision), nil) + versioningClient.On("GetChanges", revision).Return(initialChanges, nil) + pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) + versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) changeSet, err := core.ParseConfiguration(configuration, systemConfiguration, tracker, pathMatcher) @@ -99,21 +103,18 @@ var _ = Describe("Configuration parser", func() { }) It("pre-computes the header contents with a different comment style", func() { - tracker.On("ReadLinesAtLastExecutionRevision", "some-header"). - Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}\n\nSome fictional license"), nil) - tracker.On("GetLastExecutionRevision").Return("some-sha", nil) - versioningClient.On("GetChanges", "some-sha").Return(initialChanges, nil) - pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) - versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) - - configuration := core.Configuration{ + configuration := &core.Configuration{ HeaderFile: "some-header", CommentStyle: "SlashStar", Includes: includes, Excludes: excludes, - TemplateData: map[string]string{ - "Owner": "ACME Labs", - }} + TemplateData: data, + } + tracker.On("RetrieveVersionedTemplate", configuration). + Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}\n\nSome fictional license", data, revision), nil) + versioningClient.On("GetChanges", revision).Return(initialChanges, nil) + pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) + versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) changeSet, err := core.ParseConfiguration(configuration, systemConfiguration, tracker, pathMatcher) @@ -127,21 +128,18 @@ var _ = Describe("Configuration parser", func() { }) It("pre-computes a regex that allows to detect headers", func() { - tracker.On("ReadLinesAtLastExecutionRevision", "some-header"). - Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}"), nil) - tracker.On("GetLastExecutionRevision").Return("some-sha", nil) - versioningClient.On("GetChanges", "some-sha").Return(initialChanges, nil) - pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) - versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) - - configuration := core.Configuration{ + configuration := &core.Configuration{ HeaderFile: "some-header", CommentStyle: "SlashStar", Includes: includes, Excludes: excludes, - TemplateData: map[string]string{ - "Owner": "ACME Labs", - }} + TemplateData: data, + } + tracker.On("RetrieveVersionedTemplate", configuration). + Return(unchangedHeaderContents("Copyright {{.Year}} {{.Owner}}", data, revision), nil) + versioningClient.On("GetChanges", revision).Return(initialChanges, nil) + pathMatcher.On("MatchFiles", initialChanges, includes, excludes, fileSystem).Return(resultingChanges) + versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) changeSet, err := core.ParseConfiguration(configuration, systemConfiguration, tracker, pathMatcher) @@ -159,14 +157,48 @@ var _ = Describe("Configuration parser", func() { Expect(regex.MatchString("// Copyright 2009-2012 ACME!")).To(BeTrue(), "Regex should match contents with different data and comment style") }) + + It("computes the header regex based on previous configuration", func() { + configuration := &core.Configuration{ + HeaderFile: "some-header", + CommentStyle: "SlashSlash", + Includes: includes, + Excludes: excludes, + TemplateData: data, + } + tracker.On("RetrieveVersionedTemplate", configuration). + Return(&core.VersionedHeaderTemplate{ + Current: template("new\nheader {{.Owner}}", map[string]string{"Owner": "Someone"}), + Revision: revision, + Previous: template("{{.Notice}} - old\nheader", map[string]string{"Notice": "Redding"}), + }, nil) + pathMatcher.On("ScanAllFiles", includes, excludes, fileSystem).Return(resultingChanges, nil) + versioningClient.On("AddMetadata", resultingChanges, clock).Return(resultingChanges, nil) + + changeSet, err := core.ParseConfiguration(configuration, systemConfiguration, tracker, pathMatcher) + + regex := changeSet.HeaderRegex + Expect(err).To(BeNil()) + Expect(regex.MatchString("// Redding - old\n// header")).To(BeTrue(), + "Regex should match headers generated from previous run") + }) }) -func unchangedHeaderContents(str string) fs.HeaderContents { - lines := strings.Split(str, "\n") - return fs.HeaderContents{ - PreviousLines: lines, - CurrentLines: lines, +func unchangedHeaderContents(lines string, data map[string]string, revision string) *core.VersionedHeaderTemplate { + unchangedTemplate := template(lines, data) + return &core.VersionedHeaderTemplate{ + Current: unchangedTemplate, + Revision: revision, + Previous: unchangedTemplate, + } +} + +func template(lines string, data map[string]string) *core.HeaderTemplate { + unchangedTemplate := &core.HeaderTemplate{ + Lines: strings.Split(lines, "\n"), + Data: data, } + return unchangedTemplate } func onlyPaths(changes []FileChange) []FileChange { diff --git a/core/execution_tracker.go b/core/execution_tracker.go new file mode 100644 index 0000000..1b97f96 --- /dev/null +++ b/core/execution_tracker.go @@ -0,0 +1,179 @@ +/* + * Copyright 2018 Florent Biville (@fbiville) + * + * 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 + * 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 + * limitations under the License. + */ + +package core + +import ( + "fmt" + "github.com/fbiville/headache/fs" + "github.com/fbiville/headache/helper" + "github.com/fbiville/headache/vcs" + "os" + "regexp" + "strings" +) + +type ExecutionTracker interface { + RetrieveVersionedTemplate(configuration *Configuration) (*VersionedHeaderTemplate, error) + TrackExecution(configurationPath *string) error +} + +type HeaderTemplate struct { + Lines []string + Data map[string]string +} + +type ExecutionVcsTracker struct { + Versioning vcs.Vcs + FileSystem *fs.FileSystem + Clock helper.Clock + ConfigLoader *ConfigurationLoader +} + +// returns the header template at its current version and at the version it was last time headache ran +func (evt *ExecutionVcsTracker) RetrieveVersionedTemplate(currentConfiguration *Configuration) (*VersionedHeaderTemplate, error) { + currentTemplate, err := evt.readCurrentTemplate(currentConfiguration) + if err != nil { + return nil, err + } + trackingPath, revision, err := evt.getPreviousExecutionRevision() + if err != nil { + return nil, err + } + if revision == "" { + return &VersionedHeaderTemplate{ + Current: currentTemplate, + Previous: currentTemplate, + Revision: "", + }, nil + } + previousConfiguration, err := evt.readPreviousConfiguration(trackingPath, revision) + if err != nil { + return nil, err + } + if previousConfiguration == nil { + return &VersionedHeaderTemplate{ + Current: currentTemplate, + Previous: currentTemplate, + Revision: "", + }, nil + } + previousTemplate, err := evt.readFormerTemplate(previousConfiguration, revision) + if err != nil { + return nil, err + } + return &VersionedHeaderTemplate{ + Current: currentTemplate, + Previous: previousTemplate, + Revision: revision, + }, nil +} + +// tracks execution and saves the current configuration path +// returns an error if something wrong occurred +func (evt *ExecutionVcsTracker) TrackExecution(configurationPath *string) (error) { + trackerPath, err := evt.getTrackerFilePath() + if err != nil && !os.IsNotExist(err) { + return err + } + timestamp := evt.Clock.Now().Unix() + contents := fmt.Sprintf("# Generated by headache | %d -- commit me!\nconfiguration:%s", timestamp, *configurationPath) + return evt.FileSystem.FileWriter.Write(trackerPath, contents, 0640) +} + +func (evt *ExecutionVcsTracker) getPreviousExecutionRevision() (string, string, error) { + path, err := evt.getTrackerFilePath() + if err != nil && os.IsNotExist(err) { + return "", "", nil + } + if err != nil { + return "", "", err + } + sha, err := evt.Versioning.LatestRevision(path) + if err != nil { + return "", "", err + } + return path, sha, nil +} + +func (evt *ExecutionVcsTracker) readPreviousConfiguration(trackingPath string, revision string) (*Configuration, error) { + previousConfigPath, err := evt.getPreviousExecutionConfigurationPath(trackingPath) + if err != nil || previousConfigPath == "" { + return nil, err + } + previousConfig, err := evt.Versioning.ShowContentAtRevision(previousConfigPath, revision) + if err != nil { + return nil, err + } + return evt.ConfigLoader.UnmarshallConfiguration([]byte(previousConfig)) +} + +func (evt *ExecutionVcsTracker) readCurrentTemplate(configuration *Configuration) (*HeaderTemplate, error) { + headerBytes, err := evt.FileSystem.FileReader.Read(configuration.HeaderFile) + if err != nil { + return nil, err + } + return template(string(headerBytes), configuration.TemplateData), nil +} + +func (evt *ExecutionVcsTracker) readFormerTemplate(configuration *Configuration, revision string) (*HeaderTemplate, error) { + previousHeader, err := evt.Versioning.ShowContentAtRevision(configuration.HeaderFile, revision) + if err != nil { + return nil, err + } + return template(previousHeader, configuration.TemplateData), nil +} + +func template(contents string, data map[string]string) *HeaderTemplate { + return &HeaderTemplate{ + Lines: strings.Split(contents, "\n"), + Data: data, + } +} + +func (evt *ExecutionVcsTracker) getPreviousExecutionConfigurationPath(trackingPath string) (string, error) { + bytes, err := evt.FileSystem.FileReader.Read(trackingPath) + if err != nil { + return "", err + } + regex := regexp.MustCompile("(?ms)^configuration:(.*)") + result := regex.FindStringSubmatch(string(bytes)) + if result == nil || len(result) < 2 { + return "", nil + } + return result[1], nil +} + +func (evt *ExecutionVcsTracker) getTrackerFilePath() (string, error) { + versioning := evt.Versioning + root, err := versioning.Root() + if err != nil { + return "", err + } + path := fmt.Sprintf("%s/%s", root, ".headache-run") + fileSystem := evt.FileSystem + info, err := fileSystem.FileReader.Stat(path) + if os.IsNotExist(err) { + return path, err + } + if err != nil { + return "", err + } + if info != nil && !info.Mode().IsRegular() { + return "", fmt.Errorf("'%s' should be a regular file", path) + } + return path, nil +} diff --git a/core/execution_tracker_test.go b/core/execution_tracker_test.go new file mode 100644 index 0000000..5d09f32 --- /dev/null +++ b/core/execution_tracker_test.go @@ -0,0 +1,348 @@ +/* + * Copyright 2018-2019 Florent Biville (@fbiville) + * + * 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 + * 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 + * limitations under the License. + */ + +package core_test + +import ( + "errors" + "fmt" + "github.com/fbiville/headache/core" + . "github.com/fbiville/headache/fs" + "github.com/fbiville/headache/fs_mocks" + "github.com/fbiville/headache/vcs_mocks" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "os" + "reflect" + "strings" + "time" +) + +type FixedClock struct{} + +func (*FixedClock) Now() time.Time { + return time.Unix(42, 42) +} + +var _ = Describe("The execution tracker", func() { + + var ( + t GinkgoTInterface + vcs *vcs_mocks.Vcs + fileReader *fs_mocks.FileReader + fileWriter *fs_mocks.FileWriter + tracker core.ExecutionVcsTracker + ) + + BeforeEach(func() { + t = GinkgoT() + vcs = new(vcs_mocks.Vcs) + fileReader = new(fs_mocks.FileReader) + fileWriter = new(fs_mocks.FileWriter) + tracker = core.ExecutionVcsTracker{ + Versioning: vcs, + FileSystem: &FileSystem{FileReader: fileReader, FileWriter: fileWriter}, + Clock: &FixedClock{}, + } + }) + + AfterEach(func() { + vcs.AssertExpectations(t) + fileReader.AssertExpectations(t) + fileWriter.AssertExpectations(t) + }) + + Describe("when retrieving the versioned header template", func() { + + var ( + currentHeaderFile string + currentData map[string]string + currentConfiguration *core.Configuration + fakeRepositoryRoot string + trackerFilePath string + ) + + BeforeEach(func() { + currentHeaderFile = "current-header-file" + currentData = map[string]string{"foo": "bar"} + currentConfiguration = &core.Configuration{ + HeaderFile: currentHeaderFile, + TemplateData: currentData, + } + fakeRepositoryRoot = "/path/to" + trackerFilePath = fakeRepositoryRoot + "/.headache-run" + }) + + It("returns only the current contents if there were no previous execution", func() { + currentContents := "some\nheader" + fileReader.On("Read", currentHeaderFile).Return([]byte(currentContents), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return("", nil) + + versionedTemplate, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(BeNil()) + Expect(versionedTemplate.Revision).To(BeEmpty()) + Expect(versionedTemplate.Current.Data).To(Equal(currentData)) + Expect(strings.Join(versionedTemplate.Current.Lines, "\n")).To(Equal(currentContents)) + Expect(versionedTemplate.Previous.Data).To(Equal(currentData)) + Expect(strings.Join(versionedTemplate.Previous.Lines, "\n")).To(Equal(currentContents)) + }) + + It("returns only the current contents if there were no tracked configuration for backwards compatibility", func() { + currentContents := "some\nheader" + fileReader.On("Read", currentHeaderFile).Return([]byte(currentContents), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return("some-sha", nil) + fileReader.On("Read", trackerFilePath).Return([]byte("no tracked configuration in here"), nil) + + versionedTemplate, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(BeNil()) + Expect(versionedTemplate.Revision).To(BeEmpty()) + Expect(versionedTemplate.Current.Data).To(Equal(currentData)) + Expect(strings.Join(versionedTemplate.Current.Lines, "\n")).To(Equal(currentContents)) + Expect(versionedTemplate.Previous.Data).To(Equal(currentData)) + Expect(strings.Join(versionedTemplate.Previous.Lines, "\n")).To(Equal(currentContents)) + }) + + It("returns the current and previous contents", func() { + previousConfigFile := "previous-config" + revision := "some-revision" + previousHeaderFile := "previous-header" + currentContents := "some\nheader" + previousContents := "previous\nheader" + fileReader.On("Read", currentHeaderFile).Return([]byte(currentContents), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return(revision, nil) + fileReader.On("Read", trackerFilePath).Return([]byte("configuration:"+previousConfigFile), nil) + vcs.On("ShowContentAtRevision", previousConfigFile, revision).Return(fmt.Sprintf(`{ + "headerFile": "%s", + "data": {"some": "thing"} +}`, previousHeaderFile), nil) + vcs.On("ShowContentAtRevision", previousHeaderFile, revision).Return(previousContents, nil) + + result, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(BeNil()) + Expect(strings.Join(result.Current.Lines, "\n")).To(Equal(currentContents)) + Expect(result.Current.Data).To(Equal(currentData)) + Expect(result.Revision).To(Equal(revision)) + Expect(strings.Join(result.Previous.Lines, "\n")).To(Equal(previousContents)) + Expect(result.Previous.Data).To(Equal(map[string]string{"some": "thing"})) + }) + + It("fails of the current template cannot be read", func() { + expectedError := errors.New("read error") + fileReader.On("Read", currentHeaderFile).Return(nil, expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if the current repository root cannot be determined", func() { + expectedError := errors.New("root error") + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return("", expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if it cannot get stats on the tracker file", func() { + expectedError := errors.New("stat error") + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(nil, expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if the tracker file is not a regular file", func() { + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: os.ModeDir}, nil) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(fmt.Sprintf("'%s' should be a regular file", trackerFilePath))) + }) + + It("fails if the latest execution's revision cannot be retrieved", func() { + expectedError := errors.New("revision error") + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return("", expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if the tracking file cannot be read", func() { + expectedError := errors.New("read error") + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return("some-revision", nil) + fileReader.On("Read", trackerFilePath).Return(nil, expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if the tracking file contents cannot be shown at last execution's revision", func() { + expectedError := errors.New("show at revision error") + previousConfigFile := "previous-config" + revision := "some-revision" + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return(revision, nil) + fileReader.On("Read", trackerFilePath).Return([]byte("configuration:"+previousConfigFile), nil) + vcs.On("ShowContentAtRevision", previousConfigFile, revision).Return("", expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + + It("fails if the previous configuration cannot be unmarshalled", func() { + previousConfigFile := "previous-config" + revision := "some-revision" + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return(revision, nil) + fileReader.On("Read", trackerFilePath).Return([]byte("configuration:"+previousConfigFile), nil) + vcs.On("ShowContentAtRevision", previousConfigFile, revision).Return("not-json", nil) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(reflect.TypeOf(err).String()).To(Equal("*json.SyntaxError")) + }) + + It("fails if the previous header file cannot be read", func() { + expectedError := errors.New("show at revision error") + previousConfigFile := "previous-config" + revision := "some-revision" + previousHeaderFile := "previous-header" + fileReader.On("Read", currentHeaderFile).Return([]byte("some\nheader"), nil) + vcs.On("Root").Return(fakeRepositoryRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + vcs.On("LatestRevision", trackerFilePath).Return(revision, nil) + fileReader.On("Read", trackerFilePath).Return([]byte("configuration:"+previousConfigFile), nil) + vcs.On("ShowContentAtRevision", previousConfigFile, revision).Return(fmt.Sprintf(`{ + "headerFile": "%s", + "data": {} +}`, previousHeaderFile), nil) + vcs.On("ShowContentAtRevision", previousHeaderFile, revision).Return("", expectedError) + + _, err := tracker.RetrieveVersionedTemplate(currentConfiguration) + + Expect(err).To(MatchError(expectedError)) + }) + }) + + Describe("when tracking headache execution", func() { + + var ( + repositoryFakeRoot string + trackerFilePath string + configurationPath *string + fileMode os.FileMode + expectedFileContents string + ) + + BeforeEach(func() { + repositoryFakeRoot = "/path/to" + trackerFilePath = repositoryFakeRoot + "/.headache-run" + configPath := "/path/to/headache.json" + configurationPath = &configPath + fileMode = 0640 + expectedFileContents = fmt.Sprintf(`# Generated by headache | 42 -- commit me! +configuration:%s`, configPath) + }) + + It("saves the timestamp and the path to the runtime configuration file without prior tracking file", func() { + vcs.On("Root").Return(repositoryFakeRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(nil, os.ErrNotExist) + fileWriter.On("Write", trackerFilePath, expectedFileContents, fileMode).Return(nil) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(BeNil()) + }) + + It("saves the timestamp and the path to the runtime configuration file with prior tracking file", func() { + vcs.On("Root").Return(repositoryFakeRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + fileWriter.On("Write", trackerFilePath, expectedFileContents, fileMode).Return(nil) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(BeNil()) + }) + + It("fails if the repository root cannot be retrieved", func() { + rootErr := errors.New("root error") + vcs.On("Root").Return("", rootErr) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(MatchError(rootErr)) + }) + + It("fails if the tracker path is not a regular file", func() { + vcs.On("Root").Return(repositoryFakeRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: os.ModeDir}, nil) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(MatchError(fmt.Sprintf("'%s' should be a regular file", trackerFilePath))) + }) + + It("fails if the stat call failed", func() { + statError := errors.New("stat fail") + vcs.On("Root").Return(repositoryFakeRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(nil, statError) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(MatchError(statError)) + }) + + It("fails if the write call failed", func() { + writeError := errors.New("write fail") + vcs.On("Root").Return(repositoryFakeRoot, nil) + fileReader.On("Stat", trackerFilePath).Return(&FakeFileInfo{FileMode: 0777}, nil) + fileWriter.On("Write", trackerFilePath, expectedFileContents, fileMode).Return(writeError) + + err := tracker.TrackExecution(configurationPath) + + Expect(err).To(MatchError(writeError)) + }) + }) +}) diff --git a/core/headache.go b/core/headache.go index 5d50a3c..147fb83 100644 --- a/core/headache.go +++ b/core/headache.go @@ -30,7 +30,7 @@ import ( type VcsChangeGetter func(vcs.Vcs, string, string) (error, []vcs.FileChange) -func Run(config *ChangeSet, fileSystem fs.FileSystem) { +func Run(config *ChangeSet, fileSystem *fs.FileSystem) { for _, change := range config.Files { path := change.Path bytes, err := fileSystem.FileReader.Read(path) @@ -46,7 +46,7 @@ func Run(config *ChangeSet, fileSystem fs.FileSystem) { fileContents = strings.TrimLeft(fileContents[:matchLocation[0]]+fileContents[matchLocation[1]:], "\n") } - finalHeaderContent, err := insertYears(config.HeaderContents, change, existingHeader) + finalHeaderContent, err := insertYears(config.HeaderContents, &change, existingHeader) if err != nil { log.Fatalf("headache execution error, cannot parse header for file %s\n\t%v", path, err) } @@ -55,7 +55,7 @@ func Run(config *ChangeSet, fileSystem fs.FileSystem) { } } -func insertYears(template string, change vcs.FileChange, existingHeader string) (string, error) { +func insertYears(template string, change *vcs.FileChange, existingHeader string) (string, error) { t, err := tpl.New("header-second-pass").Parse(template) if err != nil { return "", err @@ -74,7 +74,7 @@ func insertYears(template string, change vcs.FileChange, existingHeader string) return builder.String(), nil } -func computeCopyrightYears(change vcs.FileChange, existingHeader string) (string, error) { +func computeCopyrightYears(change *vcs.FileChange, existingHeader string) (string, error) { regex := regexp.MustCompile(`(\d{4})(?:\s*-\s*(\d{4}))?`) matches := regex.FindStringSubmatch(existingHeader) creationYear := change.CreationYear diff --git a/core/headache_test.go b/core/headache_test.go index eee031d..aad9332 100644 --- a/core/headache_test.go +++ b/core/headache_test.go @@ -31,7 +31,7 @@ var _ = Describe("Headache", func() { t GinkgoTInterface fileReader *fs_mocks.FileReader fileWriter *fs_mocks.FileWriter - fileSystem fs.FileSystem + fileSystem *fs.FileSystem delimiter string ) @@ -39,7 +39,7 @@ var _ = Describe("Headache", func() { t = GinkgoT() fileReader = new(fs_mocks.FileReader) fileWriter = new(fs_mocks.FileWriter) - fileSystem = fs.FileSystem{FileWriter: fileWriter, FileReader: fileReader} + fileSystem = &fs.FileSystem{FileWriter: fileWriter, FileReader: fileReader} delimiter = "\n\n" }) diff --git a/core/template_parser.go b/core/template_parser.go index 805da48..2e4f87e 100644 --- a/core/template_parser.go +++ b/core/template_parser.go @@ -17,7 +17,6 @@ package core import ( - "github.com/fbiville/headache/fs" tpl "html/template" "regexp" "strings" @@ -28,9 +27,9 @@ type templateResult struct { detectionRegex *regexp.Regexp } -func ParseTemplate(headerContents fs.HeaderContents, data map[string]string, style CommentStyle) (*templateResult, error) { - data["Year"] = "{{.Year}}" // injects reserved parameter into template, which will be parsed again, file by file - commentedLines, err := applyComments(headerContents.CurrentLines, style) +func ParseTemplate(versionedHeader *VersionedHeaderTemplate, style CommentStyle) (*templateResult, error) { + currentData := injectReservedYearParameter(versionedHeader.Current.Data) + commentedLines, err := applyComments(versionedHeader.Current.Lines, style) if err != nil { return nil, err } @@ -39,11 +38,13 @@ func ParseTemplate(headerContents fs.HeaderContents, data map[string]string, sty return nil, err } builder := &strings.Builder{} - err = template.Execute(builder, data) + err = template.Execute(builder, currentData) if err != nil { return nil, err } - regex, err := ComputeDetectionRegex(headerContents.PreviousLines, data) + + previousData := injectReservedYearParameter(versionedHeader.Previous.Data) + regex, err := ComputeDetectionRegex(versionedHeader.Previous.Lines, previousData) if err != nil { return nil, err } @@ -53,6 +54,13 @@ func ParseTemplate(headerContents fs.HeaderContents, data map[string]string, sty }, nil } +// injects reserved parameter into template data map +// the template will be parsed a second time, file by file, with the computed .Year value +func injectReservedYearParameter(currentData map[string]string) map[string]string { + currentData["Year"] = "{{.Year}}" + return currentData +} + func applyComments(lines []string, style CommentStyle) ([]string, error) { result := make([]string, 0) if openingLine := style.GetOpeningString(); openingLine != "" { @@ -73,4 +81,4 @@ func prependLine(style CommentStyle, line string) string { return strings.TrimRight(comment, " ") } return comment + line -} \ No newline at end of file +} diff --git a/core/versioned_header_template.go b/core/versioned_header_template.go new file mode 100644 index 0000000..1e422d6 --- /dev/null +++ b/core/versioned_header_template.go @@ -0,0 +1,15 @@ +package core + +import "github.com/fbiville/headache/helper" + +type VersionedHeaderTemplate struct { + Current *HeaderTemplate + Previous *HeaderTemplate + Revision string +} + +func (t VersionedHeaderTemplate) RequiresFullScan() bool { + return t.Revision == "" || + !helper.SliceEqual(t.Current.Lines, t.Previous.Lines) || + !helper.SliceEqual(helper.Keys(t.Current.Data), helper.Keys(t.Previous.Data)) +} diff --git a/core/versioned_header_template_test.go b/core/versioned_header_template_test.go new file mode 100644 index 0000000..b0fb6c7 --- /dev/null +++ b/core/versioned_header_template_test.go @@ -0,0 +1,46 @@ +package core_test + +import ( + . "github.com/fbiville/headache/core" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Versioned header template", func() { + + It("requires a full file scan when no revision is set", func() { + template := VersionedHeaderTemplate{Revision: ""} + + Expect(template.RequiresFullScan()).To(BeTrue()) + }) + + It("requires a full file scan if previous and current contents do not match", func() { + template := VersionedHeaderTemplate{ + Revision: "some-sha", + Current: template("current-contents", map[string]string{}), + Previous: template("previous-contents", map[string]string{}), + } + + Expect(template.RequiresFullScan()).To(BeTrue()) + }) + + It("requires a full file scan if previous and current data keys do not match", func() { + template := VersionedHeaderTemplate{ + Revision: "some-sha", + Current: template("same-contents", map[string]string{"foo": ""}), + Previous: template("same-contents", map[string]string{"baz": ""}), + } + + Expect(template.RequiresFullScan()).To(BeTrue()) + }) + + It("does not require a full file scan if revision is set and contents+data keys match", func() { + template := VersionedHeaderTemplate{ + Revision: "some-sha", + Current: template("same-contents", map[string]string{"foo": ""}), + Previous: template("same-contents", map[string]string{"foo": ""}), + } + + Expect(template.RequiresFullScan()).To(BeFalse()) + }) +}) diff --git a/core_mocks/ExecutionTracker.go b/core_mocks/ExecutionTracker.go new file mode 100644 index 0000000..33a3e42 --- /dev/null +++ b/core_mocks/ExecutionTracker.go @@ -0,0 +1,48 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package core_mocks + +import core "github.com/fbiville/headache/core" +import mock "github.com/stretchr/testify/mock" + +// ExecutionTracker is an autogenerated mock type for the ExecutionTracker type +type ExecutionTracker struct { + mock.Mock +} + +// RetrieveVersionedTemplate provides a mock function with given fields: configuration +func (_m *ExecutionTracker) RetrieveVersionedTemplate(configuration *core.Configuration) (*core.VersionedHeaderTemplate, error) { + ret := _m.Called(configuration) + + var r0 *core.VersionedHeaderTemplate + if rf, ok := ret.Get(0).(func(*core.Configuration) *core.VersionedHeaderTemplate); ok { + r0 = rf(configuration) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*core.VersionedHeaderTemplate) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*core.Configuration) error); ok { + r1 = rf(configuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TrackExecution provides a mock function with given fields: configurationPath +func (_m *ExecutionTracker) TrackExecution(configurationPath *string) error { + ret := _m.Called(configurationPath) + + var r0 error + if rf, ok := ret.Get(0).(func(*string) error); ok { + r0 = rf(configurationPath) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/fs/execution_tracker.go b/fs/execution_tracker.go deleted file mode 100644 index bdd3d4d..0000000 --- a/fs/execution_tracker.go +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2018 Florent Biville (@fbiville) - * - * 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 - * 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 - * limitations under the License. - */ - -package fs - -import ( - "fmt" - "github.com/fbiville/headache/helper" - "github.com/fbiville/headache/vcs" - "os" - "strings" -) - -type ExecutionTracker interface { - ReadLinesAtLastExecutionRevision(path string) (HeaderContents, error) - GetLastExecutionRevision() (string, error) -} - -type HeaderContents struct { - PreviousLines []string - CurrentLines []string -} - -type ExecutionVcsTracker struct { - Versioning vcs.Vcs - FileSystem FileSystem - Clock helper.Clock -} - -// returns lines of the specified file at the revision of the last execution of headache -// if headache was never executed, it returns the current contents of the file -func (evt *ExecutionVcsTracker) ReadLinesAtLastExecutionRevision(path string) (HeaderContents, error) { - bytes, err := evt.FileSystem.FileReader.Read(path) - if err != nil { - return HeaderContents{}, err - } - currentContents := strings.Split(string(bytes), "\n") - revision, err := evt.GetLastExecutionRevision() - if err != nil { - return HeaderContents{}, err - } - if revision == "" { - return HeaderContents{ - PreviousLines: currentContents, - CurrentLines: currentContents}, nil - } - contents, err := evt.Versioning.ShowContentAtRevision(path, revision) - if err != nil { - return HeaderContents{}, err - } - return HeaderContents{ - PreviousLines: strings.Split(contents, "\n"), - CurrentLines: currentContents, - }, nil -} - -// returns the revision associated with the last execution of headache -func (evt *ExecutionVcsTracker) GetLastExecutionRevision() (string, error) { - versioning := evt.Versioning - root, err := versioning.Root() - if err != nil { - return "", err - } - - path := fmt.Sprintf("%s/%s", root, ".headache-run") - - err = touchOrCreateTrackingFile(path, evt.FileSystem, evt.Clock) - if err != nil { - return "", err - } - - sha, err := versioning.LatestRevision(path) - if err != nil { - return "", err - } - return sha, nil -} - -func touchOrCreateTrackingFile(path string, fileSystem FileSystem, clock helper.Clock) error { - info, err := fileSystem.FileReader.Stat(path) - if err == nil && info != nil && !info.Mode().IsRegular() { - return fmt.Errorf("'%s' should be a regular file", path) - } - if err == nil || os.IsNotExist(err) { - return fileSystem.FileWriter.Write(path, fmt.Sprintf("# Generated by headache | %d -- commit me!", clock.Now().Unix()), 0640) - } - return err -} diff --git a/fs/execution_tracker_test.go b/fs/execution_tracker_test.go deleted file mode 100644 index 160360a..0000000 --- a/fs/execution_tracker_test.go +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright 2018-2019 Florent Biville (@fbiville) - * - * 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 - * 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 - * limitations under the License. - */ - -package fs_test - -import ( - "fmt" - . "github.com/fbiville/headache/fs" - "github.com/fbiville/headache/fs_mocks" - "github.com/fbiville/headache/vcs_mocks" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - "os" - "time" -) - -var _ = Describe("Execution tracker", func() { - var ( - t GinkgoTInterface - vcs *vcs_mocks.Vcs - fileReader *fs_mocks.FileReader - fileWriter *fs_mocks.FileWriter - tracker ExecutionVcsTracker - ) - - BeforeEach(func() { - t = GinkgoT() - vcs = new(vcs_mocks.Vcs) - fileReader = new(fs_mocks.FileReader) - fileWriter = new(fs_mocks.FileWriter) - tracker = ExecutionVcsTracker{ - Versioning: vcs, - FileSystem: FileSystem{FileReader: fileReader, FileWriter: fileWriter}, - Clock: &FixedClock{}, - } - }) - - AfterEach(func() { - vcs.AssertExpectations(t) - fileReader.AssertExpectations(t) - fileWriter.AssertExpectations(t) - }) - - Describe("when computing last revision", func() { - It("gets the last execution based on the existing tracker file", func() { - givenTrackerFileAtRevision(vcs, fileReader, fileWriter, "0xdeadbeef") - - revision, err := tracker.GetLastExecutionRevision() - - Expect(err).To(BeNil()) - Expect(revision).To(Equal("0xdeadbeef")) - }) - - It("gets an empty revision when there is no prior tracker file", func() { - givenNoTrackerFile(vcs, fileReader, fileWriter) - - revision, err := tracker.GetLastExecutionRevision() - - Expect(err).To(BeNil()) - Expect(revision).To(Equal("")) - }) - - It("propagates versioning root determination error", func() { - rootError := fmt.Errorf("root error") - vcs.On("Root").Return("", rootError) - - _, err := tracker.GetLastExecutionRevision() - - Expect(err).To(Equal(rootError)) - }) - - It("propagates filesystem stat error", func() { - fileInfoError := fmt.Errorf("fileinfo error") - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(nil, fileInfoError) - - _, err := tracker.GetLastExecutionRevision() - - Expect(err).To(Equal(fileInfoError)) - }) - - It("propagates filesystem write error when the tracking file does not exist", func() { - fileWriteError := fmt.Errorf("oopsie writie") - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(nil, os.ErrNotExist) - fileWriter.On("Write", "fake-root/.headache-run", "# Generated by headache | 42 -- commit me!", os.FileMode(0640)).Return(fileWriteError) - - _, err := tracker.GetLastExecutionRevision() - - Expect(err).To(Equal(fileWriteError)) - }) - - It("propagates filesystem touch error on the existing tracking file", func() { - touch := fmt.Errorf("touch error") - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(&FakeFileInfo{FileMode: 0777}, nil) - fileWriter.On("Write", "fake-root/.headache-run", "# Generated by headache | 42 -- commit me!", os.FileMode(0640)).Return(touch) - - _, err := tracker.GetLastExecutionRevision() - - Expect(err).To(Equal(touch)) - }) - - It("propagates last versioning revision retrieval error", func() { - latestRevisionError := fmt.Errorf("latest revision error") - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(&FakeFileInfo{FileMode: 0777}, nil) - fileWriter.On("Write", "fake-root/.headache-run", "# Generated by headache | 42 -- commit me!", os.FileMode(0640)).Return(nil) - vcs.On("LatestRevision", "fake-root/.headache-run").Return("", latestRevisionError) - - _, err := tracker.GetLastExecutionRevision() - - Expect(err).To(Equal(latestRevisionError)) - }) - }) - - Describe("when reading lines at a specific revision", func() { - It("succeeds at doing so without prior tracker file", func() { - givenNoTrackerFile(vcs, fileReader, fileWriter) - headerFile := "some-header" - fileReader.On("Read", headerFile). - Return([]byte("some very important notice\nthat noone will actually read #sadpanda"), nil) - - contents, err := tracker.ReadLinesAtLastExecutionRevision(headerFile) - - Expect(err).To(BeNil()) - Expect(contents.PreviousLines).To(Equal(contents.CurrentLines)) - Expect(contents.CurrentLines).To(Equal([]string{ - "some very important notice", - "that noone will actually read #sadpanda", - })) - }) - - It("succeeds at doing so with a prior tracker file", func() { - revision := "0xcafebabe" - givenTrackerFileAtRevision(vcs, fileReader, fileWriter, revision) - headerFile := "some-header" - vcs.On("ShowContentAtRevision", headerFile, revision). - Return("a somewhat important notice\nI hope will actually read it #foreveroptimism", nil) - fileReader.On("Read", headerFile). - Return([]byte("some very important notice\nthat noone will actually read #sadpanda"), nil) - - contents, err := tracker.ReadLinesAtLastExecutionRevision(headerFile) - - Expect(err).To(BeNil()) - Expect(contents.PreviousLines).To(Equal([]string{ - "a somewhat important notice", - "I hope will actually read it #foreveroptimism", - })) - Expect(contents.CurrentLines).To(Equal([]string{ - "some very important notice", - "that noone will actually read #sadpanda", - })) - }) - - It("propagates filesystem read error", func() { - headerFile := "some-header" - readError := fmt.Errorf("read error") - fileReader.On("Read", headerFile).Return(nil, readError) - - _, err := tracker.ReadLinesAtLastExecutionRevision(headerFile) - - Expect(err).To(Equal(readError)) - }) - - It("propagates versioning errors without prior tracker file", func() { - revision := "0xcafebabe" - givenTrackerFileAtRevision(vcs, fileReader, fileWriter, revision) - headerFile := "some-header" - fileReader.On("Read", headerFile). - Return([]byte("some very important notice\nthat noone will actually read #sadpanda"), nil) - versioningError := fmt.Errorf("versioning error") - vcs.On("ShowContentAtRevision", headerFile, revision).Return("", versioningError) - - _, err := tracker.ReadLinesAtLastExecutionRevision(headerFile) - - Expect(err).To(Equal(versioningError)) - }) - - It("propagates tracker file versioning revision retrieval error", func() { - headerFile := "some-header" - fileReader.On("Read", headerFile). - Return([]byte("some very important notice\nthat noone will actually read #sadpanda"), nil) - vcsRootError := fmt.Errorf("root error") - vcs.On("Root").Return("", vcsRootError) - - _, err := tracker.ReadLinesAtLastExecutionRevision(headerFile) - - Expect(err).To(Equal(vcsRootError)) - }) - }) -}) - -func givenTrackerFileAtRevision(vcs *vcs_mocks.Vcs, fileReader *fs_mocks.FileReader, fileWriter *fs_mocks.FileWriter, revision string) { - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(&FakeFileInfo{FileMode: 0777}, nil) - fileWriter.On("Write", "fake-root/.headache-run", "# Generated by headache | 42 -- commit me!", os.FileMode(0640)).Return(nil) - vcs.On("LatestRevision", "fake-root/.headache-run").Return(revision, nil) -} - -func givenNoTrackerFile(vcs *vcs_mocks.Vcs, fileReader *fs_mocks.FileReader, fileWriter *fs_mocks.FileWriter) { - vcs.On("Root").Return("fake-root", nil) - fileReader.On("Stat", "fake-root/.headache-run").Return(nil, os.ErrNotExist) - fileWriter.On("Write", "fake-root/.headache-run", "# Generated by headache | 42 -- commit me!", os.FileMode(0640)).Return(nil) - vcs.On("LatestRevision", "fake-root/.headache-run").Return("", nil) -} - -type FixedClock struct{} - -func (*FixedClock) Now() time.Time { - return time.Unix(42, 42) -} diff --git a/fs/filesystem.go b/fs/filesystem.go index e04416b..13ed274 100644 --- a/fs/filesystem.go +++ b/fs/filesystem.go @@ -29,8 +29,8 @@ type FileSystem struct { FileReader FileReader } -func DefaultFileSystem() FileSystem { - return FileSystem{ +func DefaultFileSystem() *FileSystem { + return &FileSystem{ FileWriter: &OsFileWriter{}, FileReader: &OsFileReader{}, } diff --git a/fs/path_matcher.go b/fs/path_matcher.go index 890cc7d..f8c90ab 100644 --- a/fs/path_matcher.go +++ b/fs/path_matcher.go @@ -22,14 +22,14 @@ import ( ) type PathMatcher interface { - ScanAllFiles(includes []string, excludes []string, filesystem FileSystem) ([]vcs.FileChange, error) - MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem FileSystem) []vcs.FileChange + ScanAllFiles(includes []string, excludes []string, filesystem *FileSystem) ([]vcs.FileChange, error) + MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem *FileSystem) []vcs.FileChange } type ZglobPathMatcher struct {} // Scans all local files based on the provided inclusion and exclusion patterns -func (*ZglobPathMatcher) ScanAllFiles(includes []string, excludes []string, filesystem FileSystem) ([]vcs.FileChange, error) { +func (*ZglobPathMatcher) ScanAllFiles(includes []string, excludes []string, filesystem *FileSystem) ([]vcs.FileChange, error) { result := make([]vcs.FileChange, 0) for _, includePattern := range includes { matches, err := zglob.Glob(includePattern) @@ -49,7 +49,7 @@ func (*ZglobPathMatcher) ScanAllFiles(includes []string, excludes []string, file } // Matches files based on the provided inclusion and exclusion patterns -func (*ZglobPathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem FileSystem) []vcs.FileChange { +func (*ZglobPathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem *FileSystem) []vcs.FileChange { result := make([]vcs.FileChange, 0) for _, change := range changes { if matches(change.Path, includes, excludes, filesystem) { @@ -59,11 +59,11 @@ func (*ZglobPathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, return result } -func matches(path string, includes []string, excludes []string, filesystem FileSystem) bool { +func matches(path string, includes []string, excludes []string, filesystem *FileSystem) bool { return matchesPattern(path, includes) && !isExcluded(path, excludes, filesystem) } -func isExcluded(path string, excludes []string, filesystem FileSystem) bool { +func isExcluded(path string, excludes []string, filesystem *FileSystem) bool { return !filesystem.IsFile(path) || matchesPattern(path, excludes) } diff --git a/fs/path_matcher_test.go b/fs/path_matcher_test.go index cbccccd..9528729 100644 --- a/fs/path_matcher_test.go +++ b/fs/path_matcher_test.go @@ -30,14 +30,14 @@ var _ = Describe("Path matcher", func() { var ( t GinkgoTInterface fileReader *fs_mocks.FileReader - fileSystem FileSystem + fileSystem *FileSystem matcher *ZglobPathMatcher ) BeforeEach(func() { t = GinkgoT() fileReader = new(fs_mocks.FileReader) - fileSystem = FileSystem{FileReader: fileReader} + fileSystem = &FileSystem{FileReader: fileReader} matcher = &ZglobPathMatcher{} }) diff --git a/fs_mocks/ExecutionTracker.go b/fs_mocks/ExecutionTracker.go deleted file mode 100644 index 498c967..0000000 --- a/fs_mocks/ExecutionTracker.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by mockery v1.0.0. DO NOT EDIT. - -package fs_mocks - -import fs "github.com/fbiville/headache/fs" -import mock "github.com/stretchr/testify/mock" - -// ExecutionTracker is an autogenerated mock type for the ExecutionTracker type -type ExecutionTracker struct { - mock.Mock -} - -// GetLastExecutionRevision provides a mock function with given fields: -func (_m *ExecutionTracker) GetLastExecutionRevision() (string, error) { - ret := _m.Called() - - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(string) - } - - var r1 error - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// ReadLinesAtLastExecutionRevision provides a mock function with given fields: path -func (_m *ExecutionTracker) ReadLinesAtLastExecutionRevision(path string) (fs.HeaderContents, error) { - ret := _m.Called(path) - - var r0 fs.HeaderContents - if rf, ok := ret.Get(0).(func(string) fs.HeaderContents); ok { - r0 = rf(path) - } else { - r0 = ret.Get(0).(fs.HeaderContents) - } - - var r1 error - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(path) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} diff --git a/fs_mocks/PathMatcher.go b/fs_mocks/PathMatcher.go index dacd2c1..8207711 100644 --- a/fs_mocks/PathMatcher.go +++ b/fs_mocks/PathMatcher.go @@ -12,11 +12,11 @@ type PathMatcher struct { } // MatchFiles provides a mock function with given fields: changes, includes, excludes, filesystem -func (_m *PathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem fs.FileSystem) []vcs.FileChange { +func (_m *PathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, excludes []string, filesystem *fs.FileSystem) []vcs.FileChange { ret := _m.Called(changes, includes, excludes, filesystem) var r0 []vcs.FileChange - if rf, ok := ret.Get(0).(func([]vcs.FileChange, []string, []string, fs.FileSystem) []vcs.FileChange); ok { + if rf, ok := ret.Get(0).(func([]vcs.FileChange, []string, []string, *fs.FileSystem) []vcs.FileChange); ok { r0 = rf(changes, includes, excludes, filesystem) } else { if ret.Get(0) != nil { @@ -28,11 +28,11 @@ func (_m *PathMatcher) MatchFiles(changes []vcs.FileChange, includes []string, e } // ScanAllFiles provides a mock function with given fields: includes, excludes, filesystem -func (_m *PathMatcher) ScanAllFiles(includes []string, excludes []string, filesystem fs.FileSystem) ([]vcs.FileChange, error) { +func (_m *PathMatcher) ScanAllFiles(includes []string, excludes []string, filesystem *fs.FileSystem) ([]vcs.FileChange, error) { ret := _m.Called(includes, excludes, filesystem) var r0 []vcs.FileChange - if rf, ok := ret.Get(0).(func([]string, []string, fs.FileSystem) []vcs.FileChange); ok { + if rf, ok := ret.Get(0).(func([]string, []string, *fs.FileSystem) []vcs.FileChange); ok { r0 = rf(includes, excludes, filesystem) } else { if ret.Get(0) != nil { @@ -41,7 +41,7 @@ func (_m *PathMatcher) ScanAllFiles(includes []string, excludes []string, filesy } var r1 error - if rf, ok := ret.Get(1).(func([]string, []string, fs.FileSystem) error); ok { + if rf, ok := ret.Get(1).(func([]string, []string, *fs.FileSystem) error); ok { r1 = rf(includes, excludes, filesystem) } else { r1 = ret.Error(1) diff --git a/helper/maps.go b/helper/maps.go new file mode 100644 index 0000000..d7c7400 --- /dev/null +++ b/helper/maps.go @@ -0,0 +1,15 @@ +package helper + +import "sort" + +// extracts string keys in ascending order +func Keys(m map[string]string) []string { + result := make([]string, len(m)) + i := 0 + for k := range m { + result[i] = k + i++ + } + sort.Strings(result) + return result +} diff --git a/helper/maps_test.go b/helper/maps_test.go new file mode 100644 index 0000000..8bffae1 --- /dev/null +++ b/helper/maps_test.go @@ -0,0 +1,24 @@ +package helper_test + +import ( + . "github.com/fbiville/headache/helper" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Maps utilities", func() { + + It("extracts keys", func() { + Expect(Keys(nil)).To(BeEmpty()) + Expect(Keys(map[string]string{})).To(BeEmpty()) + Expect(Keys(map[string]string{"foo": "_"})).To(ConsistOf("foo")) + }) + + It("extracts keys in alphabetical order", func() { + keys := Keys(map[string]string{"foo": "_", "baz": "_"}) + + Expect(keys).To(HaveLen(2)) + Expect(keys[0]).To(Equal("baz")) + Expect(keys[1]).To(Equal("foo")) + }) +}) diff --git a/helper/slices.go b/helper/slices.go index 4bc911b..cb36885 100644 --- a/helper/slices.go +++ b/helper/slices.go @@ -19,3 +19,18 @@ package helper func PrependString(head string, tail []string) []string { return append([]string{head}, tail...) } + +func SliceEqual(a, b []string) bool { + if (a == nil) != (b == nil) { + return false + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/helper/slices_test.go b/helper/slices_test.go index 09fbe52..c995da2 100644 --- a/helper/slices_test.go +++ b/helper/slices_test.go @@ -19,6 +19,7 @@ package helper_test import ( . "github.com/fbiville/headache/helper" . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" "testing/quick" ) @@ -30,6 +31,18 @@ var _ = Describe("String slices", func() { t = GinkgoT() }) + It("compares slices", func() { + Expect(SliceEqual(nil, nil)).To(BeTrue()) + Expect(SliceEqual(nil, []string{})).To(BeFalse()) + Expect(SliceEqual([]string{}, nil)).To(BeFalse()) + Expect(SliceEqual([]string{}, []string{})).To(BeTrue()) + Expect(SliceEqual([]string{"a"}, []string{})).To(BeFalse()) + Expect(SliceEqual([]string{"a"}, []string{"b"})).To(BeFalse()) + Expect(SliceEqual([]string{"a", "c"}, []string{"b", "c"})).To(BeFalse()) + Expect(SliceEqual([]string{"a", "c"}, []string{"a", "c"})).To(BeTrue()) + Expect(SliceEqual([]string{"a", "c"}, []string{"c", "a"})).To(BeFalse()) + }) + It("prepends strings to it", func() { f := func(head string, tail []string) bool { result := PrependString(head, tail) @@ -39,16 +52,5 @@ var _ = Describe("String slices", func() { t.Error(err) } }) -}) -func SliceEqual(slice1 []string, slice2 []string) bool { - if len(slice1) != len(slice2) { - return false - } - for i := range slice1 { - if slice1[i] != slice2[i] { - return false - } - } - return true -} +}) diff --git a/main.go b/main.go index af776af..9ffef80 100644 --- a/main.go +++ b/main.go @@ -17,72 +17,57 @@ package main import ( - "encoding/json" "flag" . "github.com/fbiville/headache/core" "github.com/fbiville/headache/fs" - jsonsch "github.com/xeipuuv/gojsonschema" "log" ) func main() { log.Print("Starting...") - configFile := flag.String("configuration", "headache.json", "Path to configuration file") - - flag.Parse() + // poor man's dependency graph systemConfig := DefaultSystemConfiguration() fileSystem := systemConfig.FileSystem - rawConfiguration := readConfiguration(configFile, fileSystem.FileReader) - executionTracker := &fs.ExecutionVcsTracker{ - Versioning: systemConfig.VersioningClient.GetClient(), - FileSystem: fileSystem, - Clock: systemConfig.Clock, + configLoader := &ConfigurationLoader{ + Reader: fileSystem.FileReader, } - matcher := &fs.ZglobPathMatcher{} - configuration, err := ParseConfiguration(rawConfiguration, systemConfig, executionTracker, matcher) - if err != nil { - log.Fatalf("headache configuration error, cannot parse\n\t%v\n", err) + executionTracker := &ExecutionVcsTracker{ + Versioning: systemConfig.VersioningClient.GetClient(), + FileSystem: fileSystem, + Clock: systemConfig.Clock, + ConfigLoader: configLoader, } - Run(configuration, fileSystem) - log.Print("Done!") -} + matcher := &fs.ZglobPathMatcher{} -func readConfiguration(configFile *string, reader fs.FileReader) Configuration { - validateConfiguration(reader, configFile) + configFile := parseFlags() - file, err := reader.Read(*configFile) + userConfiguration, err := configLoader.ReadConfiguration(configFile) if err != nil { - log.Fatalf("headache configuration error, cannot read file:\n\t%v", err) + log.Fatalf("headache configuration error, cannot load\n\t%v\n", err) } - result := Configuration{} - err = json.Unmarshal(file, &result) + + configuration, err := ParseConfiguration(userConfiguration, systemConfig, executionTracker, matcher) if err != nil { - log.Fatalf("headache configuration error, cannot unmarshall JSON:\n\t%v", err) + log.Fatalf("headache configuration error, cannot parse\n\t%v\n", err) } - return result -} -func validateConfiguration(reader fs.FileReader, configFile *string) { - schema := loadSchema() - if schema == nil { - return - } - jsonSchemaValidator := JsonSchemaValidator{ - Schema: schema, - FileReader: reader, - } - validationError := jsonSchemaValidator.Validate("file://" + *configFile) - if validationError != nil { - log.Fatalf("headache configuration error, validation failed\n\t%s\n", validationError) + if len(configuration.Files) > 0 { + Run(configuration, fileSystem) + trackRun(configFile, executionTracker) } + log.Print("Done!") +} + +func parseFlags() *string { + configFile := flag.String("configuration", "headache.json", "Path to configuration file") + flag.Parse() + return configFile } -func loadSchema() *jsonsch.Schema { - schema, err := jsonsch.NewSchema(jsonsch.NewReferenceLoader("https://fbiville.github.io/headache/v1.0.0-M01/schema.json")) +func trackRun(configFile *string, tracker ExecutionTracker) { + err := tracker.TrackExecution(configFile) if err != nil { - log.Printf("headache configuration warning: cannot load schema, skipping configuration validation. See reason below:\n\t%v\n", err) - return nil + log.Printf("headache warning, could not save current execution, see below for details\n\t%v\n", err) } - return schema } diff --git a/vcs/vcs.go b/vcs/vcs.go index b5a8484..4450582 100644 --- a/vcs/vcs.go +++ b/vcs/vcs.go @@ -35,23 +35,23 @@ type Vcs interface { } type Git struct{} -func (Git) Status(args ...string) (string, error) { +func (*Git) Status(args ...string) (string, error) { return git(PrependString("status", args)...) } -func (Git) Diff(args ...string) (string, error) { +func (*Git) Diff(args ...string) (string, error) { return git(PrependString("diff", args)...) } -func (g Git) LatestRevision(file string) (string, error) { +func (g *Git) LatestRevision(file string) (string, error) { result, err := g.Log("-1", `--format=%H`, "--", file) if err != nil { return "", err } return strings.Trim(result, "\n"), nil } -func (Git) Log(args ...string) (string, error) { +func (*Git) Log(args ...string) (string, error) { return git(PrependString("log", args)...) } -func (Git) ShowContentAtRevision(path string, revision string) (string, error) { +func (*Git) ShowContentAtRevision(path string, revision string) (string, error) { if revision == "" { return "", nil } @@ -62,7 +62,7 @@ func (Git) ShowContentAtRevision(path string, revision string) (string, error) { fullRevision = strings.Trim(fullRevision, "\n") return git("cat-file", "-p", fmt.Sprintf("%s:%s", fullRevision, path)) } -func (Git) Root() (string, error) { +func (*Git) Root() (string, error) { result, err := git("rev-parse", "--show-toplevel") if err != nil { return "", err