commit c10ba9c097ba2d5e0d5e834ae43252276a177e94 Author: Vincent Batts Date: Mon Oct 5 16:16:52 2015 -0400 Initial commit Signed-off-by: Vincent Batts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..265db6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +git-validation diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8efd59c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Vincent Batts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b35008 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# git-validation + +A way to do per git commit validation + + diff --git a/git/commits.go b/git/commits.go new file mode 100644 index 0000000..7516815 --- /dev/null +++ b/git/commits.go @@ -0,0 +1,105 @@ +package git + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +// Commits returns a set of commits. +// If commitrange is a git still range 12345...54321, then it will be isolated set of commits. +// If commitrange is a single commit, all ancestor commits up through the hash provided. +func Commits(commitrange string) ([]CommitEntry, error) { + output, err := exec.Command("git", "log", prettyFormat+formatCommit, commitrange).Output() + if err != nil { + return nil, err + } + commitHashes := strings.Split(strings.TrimSpace(string(output)), "\n") + commits := make([]CommitEntry, len(commitHashes)) + for i, commitHash := range commitHashes { + c, err := LogCommit(commitHash) + if err != nil { + return commits, err + } + commits[i] = *c + } + return commits, nil +} + +// CommitEntry represents a single commit's information from `git` +type CommitEntry map[string]string + +var ( + prettyFormat = `--pretty=format:` + formatSubject = `%s` + formatBody = `%b` + formatCommit = `%H` + formatAuthorName = `%aN` + formatAuthorEmail = `%aE` + formatCommitterName = `%cN` + formatCommitterEmail = `%cE` + formatSigner = `%GS` + formatCommitNotes = `%N` + formatMap = `{"commit": "%H", "abbreviated_commit": "%h", "tree": "%T", "abbreviated_tree": "%t", "parent": "%P", "abbreviated_parent": "%p", "refs": "%D", "encoding": "%e", "sanitized_subject_line": "%f", "verification_flag": "%G?", "signer_key": "%GK", "author_date": "%aD" , "committer_date": "%cD" }` +) + +// LogCommit assembles the full information on a commit from its commit hash +func LogCommit(commit string) (*CommitEntry, error) { + buf := bytes.NewBuffer([]byte{}) + cmd := exec.Command("git", "log", "-1", prettyFormat+formatMap, commit) + cmd.Stdout = buf + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Println(strings.Join(cmd.Args, " ")) + return nil, err + } + c := CommitEntry{} + output := buf.Bytes() + if err := json.Unmarshal(output, &c); err != nil { + fmt.Println(string(output)) + return nil, err + } + + // any user provided fields can't be sanitized for the mock-json marshal above + for k, v := range map[string]string{ + "subject": formatSubject, + "body": formatBody, + "author_name": formatAuthorName, + "author_email": formatAuthorEmail, + "committer_name": formatCommitterName, + "committer_email": formatCommitterEmail, + "commit_notes": formatCommitNotes, + "signer": formatSigner, + } { + output, err := exec.Command("git", "log", "-1", prettyFormat+v, commit).Output() + if err != nil { + return nil, err + } + c[k] = strings.TrimSpace(string(output)) + } + + return &c, nil +} + +// FetchHeadCommit returns the hash of FETCH_HEAD +func FetchHeadCommit() (string, error) { + output, err := exec.Command("git", "rev-parse", "--verify", "FETCH_HEAD").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// HeadCommit returns the hash of HEAD +func HeadCommit() (string, error) { + output, err := exec.Command("git", "rev-parse", "--verify", "HEAD").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5442dfb --- /dev/null +++ b/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/vbatts/git-validation/git" + _ "github.com/vbatts/git-validation/rules/dco" + "github.com/vbatts/git-validation/validate" +) + +var ( + flCommitRange = flag.String("range", "", "use this commit range instead") + flListRules = flag.Bool("list-rules", false, "list the rules registered") + flRun = flag.String("run", "", "comma delimited list of rules to run. Defaults to all.") + flVerbose = flag.Bool("v", false, "verbose") +) + +func main() { + flag.Parse() + + if *flListRules { + for _, r := range validate.RegisteredRules { + fmt.Printf("%q -- %s\n", r.Name, r.Description) + } + return + } + + var commitrange string + if *flCommitRange != "" { + commitrange = *flCommitRange + } else { + var err error + commitrange, err = git.FetchHeadCommit() + if err != nil { + log.Fatal(err) + } + } + + c, err := git.Commits(commitrange) + if err != nil { + log.Fatal(err) + } + + results := validate.Results{} + for _, commit := range c { + fmt.Printf(" * %s %s ... ", commit["abbreviated_commit"], commit["subject"]) + vr := validate.Commit(commit, validate.RegisteredRules) + results = append(results, vr...) + if _, fail := vr.PassFail(); fail == 0 { + fmt.Println("PASS") + if *flVerbose { + for _, r := range vr { + if r.Pass { + fmt.Printf(" - %s\n", r.Msg) + } + } + } + } else { + fmt.Println("FAIL") + // default, only print out failed validations + for _, r := range vr { + if !r.Pass { + fmt.Printf(" - %s\n", r.Msg) + } + } + } + } + _, fail := results.PassFail() + if fail > 0 { + fmt.Printf("%d issues to fix\n", fail) + os.Exit(1) + } +} diff --git a/rules/dco/dco.go b/rules/dco/dco.go new file mode 100644 index 0000000..b0867ba --- /dev/null +++ b/rules/dco/dco.go @@ -0,0 +1,47 @@ +package dco + +import ( + "regexp" + "strings" + + "github.com/vbatts/git-validation/git" + "github.com/vbatts/git-validation/validate" +) + +func init() { + validate.RegisterRule(DcoRule) +} + +var ( + ValidDCO = regexp.MustCompile(`^Signed-off-by: ([^<]+) <([^<>@]+@[^<>]+)>$`) + DcoRule = validate.Rule{ + Name: "DCO", + Description: "makes sure the commits are signed", + Run: ValidateDCO, + } +) + +func ValidateDCO(c git.CommitEntry) (vr validate.Result) { + vr.CommitEntry = c + if len(strings.Split(c["parent"], " ")) > 1 { + vr.Pass = true + vr.Msg = "merge commits do not require DCO" + return vr + } + + hasValid := false + for _, line := range strings.Split(c["body"], "\n") { + if ValidDCO.MatchString(line) { + hasValid = true + } + } + if !hasValid { + vr.Pass = false + vr.Msg = "does not have a valid DCO" + } else { + vr.Pass = true + vr.Msg = "has a valid DCO" + } + + return vr +} diff --git a/validate/rules.go b/validate/rules.go new file mode 100644 index 0000000..21ad613 --- /dev/null +++ b/validate/rules.go @@ -0,0 +1,51 @@ +package validate + +import "github.com/vbatts/git-validation/git" + +var ( + // RegisteredRules are the standard validation to perform on git commits + RegisteredRules = []Rule{} +) + +// RegisterRule includes the Rule in the avaible set to use +func RegisterRule(vr Rule) { + RegisteredRules = append(RegisteredRules, vr) +} + +// Rule will operate over a provided git.CommitEntry, and return a result. +type Rule struct { + Name string // short name for reference in in the `-run=...` flag + Description string // longer Description for readability + Run func(git.CommitEntry) Result +} + +// Commit processes the given rules on the provided commit, and returns the result set. +func Commit(c git.CommitEntry, rules []Rule) Results { + results := Results{} + for _, r := range rules { + results = append(results, r.Run(c)) + } + return results +} + +// Result is the result for a single validation of a commit. +type Result struct { + CommitEntry git.CommitEntry + Pass bool + Msg string +} + +// Results is a set of results. This is type makes it easy for the following function. +type Results []Result + +// PassFail gives a quick over/under of passes and failures of the results in this set +func (vr Results) PassFail() (pass int, fail int) { + for _, res := range vr { + if res.Pass { + pass++ + } else { + fail++ + } + } + return pass, fail +}