Merge pull request #607 from sinbad/test-enhancements

Add enhanced test setup tools for 'go test' and integration tests
This commit is contained in:
risk danger olson 2015-08-26 10:30:17 -06:00
commit e8a21619a9
6 changed files with 720 additions and 0 deletions

@ -7,10 +7,25 @@ import (
"os"
"os/exec"
"strings"
"time"
"github.com/github/git-lfs/vendor/_nuts/github.com/rubyist/tracerx"
)
// Some top level information about a commit (only first line of message)
type CommitSummary struct {
Sha string
ShortSha string
Parents []string
CommitDate time.Time
AuthorDate time.Time
AuthorName string
AuthorEmail string
CommitterName string
CommitterEmail string
Subject string
}
func LsRemote(remote, remoteRef string) (string, error) {
if remote == "" {
return "", errors.New("remote required")
@ -134,3 +149,58 @@ func simpleExec(name string, args ...string) (string, error) {
return strings.Trim(string(output), " \n"), nil
}
// Parse a Git date formatted in ISO 8601 format (%ci/%ai)
func ParseGitDate(str string) (time.Time, error) {
// Unfortunately Go and Git don't overlap in their builtin date formats
// Go's time.RFC1123Z and Git's %cD are ALMOST the same, except that
// when the day is < 10 Git outputs a single digit, but Go expects a leading
// zero - this is enough to break the parsing. Sigh.
// Format is for 2 Jan 2006, 15:04:05 -7 UTC as per Go
return time.Parse("2006-01-02 15:04:05 -0700", str)
}
// FormatGitDate converts a Go date into a git command line format date
func FormatGitDate(tm time.Time) string {
// Git format is "Fri Jun 21 20:26:41 2013 +0900" but no zero-leading for day
return tm.Format("Mon Jan 2 15:04:05 2006 -0700")
}
// Get summary information about a commit
func GetCommitSummary(commit string) (*CommitSummary, error) {
cmd := execCommand("git", "show", "-s",
`--format=%H|%h|%P|%ai|%ci|%ae|%an|%ce|%cn|%s`, commit)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("Failed to call git show: %v %v", err, string(out))
}
// At most 10 substrings so subject line is not split on anything
fields := strings.SplitN(string(out), "|", 10)
// Cope with the case where subject is blank
if len(fields) >= 9 {
ret := &CommitSummary{}
// Get SHAs from output, not commit input, so we can support symbolic refs
ret.Sha = fields[0]
ret.ShortSha = fields[1]
ret.Parents = strings.Split(fields[2], " ")
// %aD & %cD (RFC2822) matches Go's RFC1123Z format
ret.AuthorDate, _ = ParseGitDate(fields[3])
ret.CommitDate, _ = ParseGitDate(fields[4])
ret.AuthorEmail = fields[5]
ret.AuthorName = fields[6]
ret.CommitterEmail = fields[7]
ret.CommitterName = fields[8]
if len(fields) > 9 {
ret.Subject = strings.TrimRight(fields[9], "\n")
}
return ret, nil
} else {
msg := fmt.Sprintf("Unexpected output from git show: %v", string(out))
return nil, errors.New(msg)
}
}

76
lfs/scanner_git_test.go Normal file

@ -0,0 +1,76 @@
package lfs_test // to avoid import cycles
// This is for doing complete git-level tests using test utils
// Needs to be a separate file from scanner_test so that we can use a diff package
// which avoids import cycles with testutils
import (
"testing"
. "github.com/github/git-lfs/lfs"
"github.com/github/git-lfs/test"
"github.com/github/git-lfs/vendor/_nuts/github.com/technoweenie/assert"
)
func TestScanUnpushed(t *testing.T) {
repo := test.NewRepo(t)
repo.Pushd()
defer func() {
repo.Popd()
repo.Cleanup()
}()
inputs := []*test.CommitInput{
{ // 0
Files: []*test.FileInput{
{Filename: "file1.txt", Size: 20},
},
},
{ // 1
NewBranch: "branch2",
Files: []*test.FileInput{
{Filename: "file1.txt", Size: 25},
},
},
{ // 2
ParentBranches: []string{"master"}, // back on master
Files: []*test.FileInput{
{Filename: "file1.txt", Size: 30},
},
},
{ // 3
NewBranch: "branch3",
Files: []*test.FileInput{
{Filename: "file1.txt", Size: 32},
},
},
}
repo.AddCommits(inputs)
// Add a couple of remotes and test state depending on what's pushed
repo.AddRemote("origin")
repo.AddRemote("upstream")
pointers, err := ScanUnpushed()
assert.Equal(t, nil, err, "Should be no error calling ScanUnpushed")
assert.Equal(t, 4, len(pointers), "Should be 4 pointers because none pushed")
test.RunGitCommand(t, true, "push", "origin", "branch2")
// Branch2 will have pushed 2 commits
pointers, err = ScanUnpushed()
assert.Equal(t, nil, err, "Should be no error calling ScanUnpushed")
assert.Equal(t, 2, len(pointers), "Should be 2 pointers")
test.RunGitCommand(t, true, "push", "upstream", "master")
// Master pushes 1 more commit
pointers, err = ScanUnpushed()
assert.Equal(t, nil, err, "Should be no error calling ScanUnpushed")
assert.Equal(t, 1, len(pointers), "Should be 1 pointer")
test.RunGitCommand(t, true, "push", "origin", "branch3")
// All pushed (somewhere)
pointers, err = ScanUnpushed()
assert.Equal(t, nil, err, "Should be no error calling ScanUnpushed")
assert.Equal(t, 0, len(pointers), "Should be 0 pointers unpushed")
}

@ -0,0 +1,85 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/github/git-lfs/test"
)
type TestUtilRepoCallback struct{}
func (*TestUtilRepoCallback) Fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format, args...)
os.Exit(4)
}
func (*TestUtilRepoCallback) Errorf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format, args...)
}
func main() {
commandMap := map[string]func(*test.Repo){
"addcommits": AddCommits,
}
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Command required (e.g. addcommits)\n")
os.Exit(2)
}
f, ok := commandMap[os.Args[1]]
if !ok {
fmt.Fprintf(os.Stderr, "Unknown command: %v\n", os.Args[1])
os.Exit(2)
}
// Construct test repo context (note: no Cleanup() call since managed outside)
// also assume we're in the same folder
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Problem getting working dir: %v\n", err)
os.Exit(2)
}
// Make sure we're directly inside directory which contains .git
// don't want to accidentally end up committing to some other parent git
_, err = os.Stat(filepath.Join(wd, ".git"))
if err != nil {
fmt.Fprintf(os.Stderr, "You're in the wrong directory, should be in root of a test repo: %v\n", err)
os.Exit(2)
}
repo := test.WrapRepo(&TestUtilRepoCallback{}, wd)
f(repo)
}
func AddCommits(repo *test.Repo) {
// Read stdin as JSON []*test.CommitInput
in, err := ioutil.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "addcommits: Unable to read input data: %v\n", err)
os.Exit(3)
}
inputs := make([]*test.CommitInput, 0)
err = json.Unmarshal(in, &inputs)
if err != nil {
fmt.Fprintf(os.Stderr, "addcommits: Unable to unmarshal JSON: %v\n%v\n", string(in), err)
os.Exit(3)
}
outputs := repo.AddCommits(inputs)
by, err := json.Marshal(outputs)
if err != nil {
fmt.Fprintf(os.Stderr, "addcommits: Unable to marshal output JSON: %v\n", err)
os.Exit(3)
}
// Write response to stdout
_, err = os.Stdout.Write(by)
if err != nil {
fmt.Fprintf(os.Stderr, "addcommits: Error writing JSON to stdout: %v\n", err)
os.Exit(3)
}
os.Stdout.WriteString("\n")
}

@ -84,3 +84,50 @@ begin_test "push object id(s)"
grep "(2 of 2 files)" push.log
)
end_test
begin_test "push modified files"
(
set -e
reponame="$(basename "$0" ".sh")-modified"
setup_remote_repo "$reponame"
clone_repo "$reponame" "$reponame"
git lfs track "*.dat"
# generate content we'll use
content1="filecontent1"
content2="filecontent2"
content3="filecontent3"
oid1=$(printf "$content1" | shasum -a 256 | cut -f 1 -d " ")
oid2=$(printf "$content2" | shasum -a 256 | cut -f 1 -d " ")
oid3=$(printf "$content3" | shasum -a 256 | cut -f 1 -d " ")
oid4=$(printf "$content4" | shasum -a 256 | cut -f 1 -d " ")
echo "[
{
\"CommitDate\":\"$(get_date -6m)\",
\"Files\":[
{\"Filename\":\"file1.dat\",\"Size\":${#content1}, \"Data\":\"$content1\"}]
},
{
\"CommitDate\":\"$(get_date -3m)\",
\"Files\":[
{\"Filename\":\"file1.dat\",\"Size\":${#content2}, \"Data\":\"$content2\"}]
},
{
\"CommitDate\":\"$(get_date -1m)\",
\"Files\":[
{\"Filename\":\"file1.dat\",\"Size\":${#content3}, \"Data\":\"$content3\"},
{\"Filename\":\"file2.dat\",\"Size\":${#content4}, \"Data\":\"$content4\"}]
}
]" | lfstest-testutils addcommits
git push origin master
assert_server_object "$reponame" "$oid1"
assert_server_object "$reponame" "$oid2"
assert_server_object "$reponame" "$oid3"
assert_server_object "$reponame" "$oid4"
)
end_test

@ -304,3 +304,41 @@ comparison_to_operator() {
echo "???"
fi
}
# Get a date string with an offset
# Args: One or more date offsets of the form (regex) "[+-]\d+[dmyHM]"
# e.g. +1d = 1 day forward from today
# -5y = 5 years before today
# Example call:
# D=$(get_date +1y +1m -5H)
# returns date as string in RFC3339 format ccyy-mm-ddThh:MM:ssZ
# note returns in UTC time not local time hence Z and not +/-
get_date() {
# Wrapped because BSD (inc OSX) & GNU 'date' functions are different
# on Windows under Git Bash it's GNU
if date --version >/dev/null 2>&1 ; then # GNU
ARGS=""
for var in "$@"
do
# GNU offsets are more verbose
unit=${var: -1}
val=${var:0:${#var}-1}
case "$unit" in
d) unit="days" ;;
m) unit="months" ;;
y) unit="years" ;;
H) unit="hours" ;;
M) unit="minutes" ;;
esac
ARGS="$ARGS $val $unit"
done
date -d "$ARGS" -u +%Y-%m-%dT%TZ
else # BSD
ARGS=""
for var in "$@"
do
ARGS="$ARGS -v$var"
done
date $ARGS -u +%Y-%m-%dT%TZ
fi
}

404
test/testutils.go Normal file

@ -0,0 +1,404 @@
package test
// Utility functions for more complex go tests
// Need to be in a separate test package so they can be imported anywhere
// Also can't add _test.go suffix to exclude from main build (import doesn't work)
// To avoid import cycles, append "_test" to the package statement of any test using
// this package and use "import . original/package/name" to get the same visibility
// as if the test was in the same package (as usual)
import (
"fmt"
"io"
"io/ioutil"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/github/git-lfs/git"
"github.com/github/git-lfs/lfs"
)
type RepoType int
const (
// Normal repo with working copy
RepoTypeNormal = RepoType(iota)
// Bare repo (no working copy)
RepoTypeBare = RepoType(iota)
// Repo with working copy but git dir is separate
RepoTypeSeparateDir = RepoType(iota)
)
type RepoCreateSettings struct {
RepoType RepoType
}
// Callback interface (testing.T compatible)
type RepoCallback interface {
// Fatalf reports error and fails
Fatalf(format string, args ...interface{})
// Errorf reports error and continues
Errorf(format string, args ...interface{})
}
type Repo struct {
// Path to the repo, working copy if non-bare
Path string
// Path to the git dir
GitDir string
// Paths to remotes
Remotes map[string]*Repo
// Settings used to create this repo
Settings *RepoCreateSettings
// Previous dir for pushd
popDir string
// Test callback
callback RepoCallback
}
// Change to repo dir but save current dir
func (r *Repo) Pushd() {
if r.popDir != "" {
r.callback.Fatalf("Cannot Pushd twice")
}
oldwd, err := os.Getwd()
if err != nil {
r.callback.Fatalf("Can't get cwd %v", err)
}
err = os.Chdir(r.Path)
if err != nil {
r.callback.Fatalf("Can't chdir %v", err)
}
r.popDir = oldwd
}
func (r *Repo) Popd() {
if r.popDir != "" {
err := os.Chdir(r.popDir)
if err != nil {
r.callback.Fatalf("Can't chdir %v", err)
}
r.popDir = ""
}
}
func (r *Repo) Cleanup() {
// pop out if necessary
r.Popd()
// Make sure cwd isn't inside a path we're going to delete
oldwd, err := os.Getwd()
if err == nil {
if strings.HasPrefix(oldwd, r.Path) ||
strings.HasPrefix(oldwd, r.GitDir) {
os.Chdir(os.TempDir())
}
}
if r.GitDir != "" {
os.RemoveAll(r.GitDir)
r.GitDir = ""
}
if r.Path != "" {
os.RemoveAll(r.Path)
r.Path = ""
}
for _, remote := range r.Remotes {
remote.Cleanup()
}
r.Remotes = nil
}
// NewRepo creates a new git repo in a new temp dir
func NewRepo(callback RepoCallback) *Repo {
return NewCustomRepo(callback, &RepoCreateSettings{RepoType: RepoTypeNormal})
}
// NewCustomRepo creates a new git repo in a new temp dir with more control over settings
func NewCustomRepo(callback RepoCallback, settings *RepoCreateSettings) *Repo {
ret := &Repo{
Settings: settings,
Remotes: make(map[string]*Repo),
callback: callback}
path, err := ioutil.TempDir("", "lfsRepo")
if err != nil {
callback.Fatalf("Can't create temp dir for git repo: %v", err)
}
ret.Path = path
args := []string{"init"}
switch settings.RepoType {
case RepoTypeBare:
args = append(args, "--bare")
ret.GitDir = ret.Path
case RepoTypeSeparateDir:
gitdir, err := ioutil.TempDir("", "lfstestgitdir")
if err != nil {
ret.Cleanup()
callback.Fatalf("Can't create temp dir for git repo: %v", err)
}
args = append(args, "--separate-dir", gitdir)
ret.GitDir = gitdir
default:
ret.GitDir = filepath.Join(ret.Path, ".git")
}
args = append(args, path)
cmd := exec.Command("git", args...)
err = cmd.Run()
if err != nil {
ret.Cleanup()
callback.Fatalf("Unable to create git repo at %v: %v", path, err)
}
// Configure default user/email so not reliant on env
ret.Pushd()
RunGitCommand(callback, true, "config", "user.name", "Git LFS Tests")
RunGitCommand(callback, true, "config", "user.email", "git-lfs@example.com")
ret.Popd()
return ret
}
// WrapRepo creates a new Repo instance for an existing git repo
func WrapRepo(c RepoCallback, path string) *Repo {
return &Repo{Path: path, callback: c, Settings: &RepoCreateSettings{RepoType: RepoTypeNormal}}
}
// Simplistic fire & forget running of git command - returns combined output
func RunGitCommand(callback RepoCallback, failureCheck bool, args ...string) string {
outp, err := exec.Command("git", args...).CombinedOutput()
if failureCheck && err != nil {
callback.Fatalf("Error running git command 'git %v': %v %v", strings.Join(args, " "), err, string(outp))
}
return string(outp)
}
// Input data for a single file in a commit
type FileInput struct {
// Name of file (required)
Filename string
// Size of file (required)
Size int64
// Input data (optional, if provided will be source of data)
DataReader io.Reader
// Input data (optional, if provided will be source of data)
Data string
}
// Input for defining commits for test repo
type CommitInput struct {
// Date that we should commit on (optional, leave blank for 'now')
CommitDate time.Time
// List of files to include in this commit
Files []*FileInput
// List of parent branches (all branches must have been created in a previous NewBranch or be master)
// Can be omitted to just use the parent of the previous commit
ParentBranches []string
// Name of a new branch we should create at this commit (optional - master not required)
NewBranch string
// Names of any tags we should create at this commit (optional)
Tags []string
// Name of committer
CommitterName string
// Email of committer
CommitterEmail string
}
// Output struct with details of commits created for test
type CommitOutput struct {
Sha string
Parents []string
Files []*lfs.Pointer
}
func commitAtDate(atDate time.Time, committerName, committerEmail, msg string) error {
var args []string
if committerName != "" && committerEmail != "" {
args = append(args, "-c", fmt.Sprintf("user.name=%v", committerName))
args = append(args, "-c", fmt.Sprintf("user.email=%v", committerEmail))
}
args = append(args, "commit", "--allow-empty", "-m", msg)
cmd := exec.Command("git", args...)
env := os.Environ()
// set GIT_COMMITTER_DATE environment var e.g. "Fri Jun 21 20:26:41 2013 +0900"
if atDate.IsZero() {
env = append(env, "GIT_COMMITTER_DATE=")
} else {
env = append(env, fmt.Sprintf("GIT_COMMITTER_DATE=%v", git.FormatGitDate(atDate)))
}
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v %v", err, string(out))
}
return nil
}
func (repo *Repo) AddCommits(inputs []*CommitInput) []*CommitOutput {
if repo.Settings.RepoType == RepoTypeBare {
repo.callback.Fatalf("Cannot use AddCommits on a bare repo; clone it & push changes instead")
}
// Change to repo working dir
oldwd, err := os.Getwd()
if err != nil {
repo.callback.Fatalf("Can't get cwd %v", err)
}
err = os.Chdir(repo.Path)
if err != nil {
repo.callback.Fatalf("Can't chdir to repo %v", err)
}
// Used to check whether we need to checkout another commit before
lastBranch := "master"
outputs := make([]*CommitOutput, 0, len(inputs))
for i, input := range inputs {
output := &CommitOutput{}
// first, are we on the correct branch
if len(input.ParentBranches) > 0 {
if input.ParentBranches[0] != lastBranch {
RunGitCommand(repo.callback, true, "checkout", input.ParentBranches[0])
lastBranch = input.ParentBranches[0]
}
}
// Is this a merge?
if len(input.ParentBranches) > 1 {
// Always take the *other* side in a merge so we adopt changes
// also don't automatically commit, we'll do that below
args := []string{"merge", "--no-ff", "--no-commit", "--strategy-option=theirs"}
args = append(args, input.ParentBranches[1:]...)
RunGitCommand(repo.callback, false, args...)
} else if input.NewBranch != "" {
RunGitCommand(repo.callback, true, "checkout", "-b", input.NewBranch)
lastBranch = input.NewBranch
}
// Any files to write?
for fi, infile := range input.Files {
inputData := infile.DataReader
if inputData == nil && infile.Data != "" {
inputData = strings.NewReader(infile.Data)
}
if inputData == nil {
// Different data for each file but deterministic
inputData = NewPlaceholderDataReader(int64(i*fi), infile.Size)
}
cleaned, err := lfs.PointerClean(inputData, infile.Filename, infile.Size, nil)
if err != nil {
repo.callback.Errorf("Error creating pointer file: %v", err)
continue
}
// this only created the temp file, move to final location
tmpfile := cleaned.Filename
mediafile, err := lfs.LocalMediaPath(cleaned.Oid)
if err != nil {
repo.callback.Errorf("Unable to get local media path: %v", err)
continue
}
if _, err := os.Stat(mediafile); err != nil {
if err := os.Rename(tmpfile, mediafile); err != nil {
repo.callback.Errorf("Unable to move %s to %s: %v", tmpfile, mediafile, err)
continue
}
}
output.Files = append(output.Files, cleaned.Pointer)
// Write pointer to local filename for adding (not using clean filter)
os.MkdirAll(filepath.Dir(infile.Filename), 0755)
f, err := os.Create(infile.Filename)
if err != nil {
repo.callback.Errorf("Error creating pointer file: %v", err)
continue
}
_, err = cleaned.Pointer.Encode(f)
if err != nil {
f.Close()
repo.callback.Errorf("Error encoding pointer file: %v", err)
continue
}
f.Close() // early close in a loop, don't defer
RunGitCommand(repo.callback, true, "add", infile.Filename)
}
// Now commit
err = commitAtDate(input.CommitDate, input.CommitterName, input.CommitterEmail,
fmt.Sprintf("Test commit %d", i))
if err != nil {
repo.callback.Fatalf("Error committing: %v", err)
}
commit, err := git.GetCommitSummary("HEAD")
if err != nil {
repo.callback.Fatalf("Error determining commit SHA: %v", err)
}
// tags
for _, tag := range input.Tags {
// Use annotated tags, assume full release tags (also tag objects have edge cases)
RunGitCommand(repo.callback, true, "tag", "-a", "-m", "Added tag", tag)
}
output.Sha = commit.Sha
output.Parents = commit.Parents
outputs = append(outputs, output)
}
// Restore cwd
err = os.Chdir(oldwd)
if err != nil {
repo.callback.Fatalf("Can't restore old cwd %v", err)
}
return outputs
}
// Add a new remote (generate a path for it to live in, will be cleaned up)
func (r *Repo) AddRemote(name string) *Repo {
if _, exists := r.Remotes[name]; exists {
r.callback.Fatalf("Remote %v already exists", name)
}
remote := NewCustomRepo(r.callback, &RepoCreateSettings{RepoTypeBare})
r.Remotes[name] = remote
RunGitCommand(r.callback, true, "remote", "add", name, remote.Path)
return remote
}
// Just a psuedo-random stream of bytes (not cryptographic)
// Calls RNG a bit less often than using rand.Source directly
type PlaceholderDataReader struct {
source rand.Source
bytesLeft int64
}
func NewPlaceholderDataReader(seed, size int64) *PlaceholderDataReader {
return &PlaceholderDataReader{rand.NewSource(seed), size}
}
func (r *PlaceholderDataReader) Read(p []byte) (int, error) {
c := len(p)
i := 0
for i < c && r.bytesLeft > 0 {
// Use all 8 bytes of the 64-bit random number
val64 := r.source.Int63()
for j := 0; j < 8 && i < c && r.bytesLeft > 0; j++ {
// Duplicate this byte 16 times (faster)
for k := 0; k < 16 && r.bytesLeft > 0; k++ {
p[i] = byte(val64)
i++
r.bytesLeft--
}
// Next byte from the 8-byte number
val64 = val64 >> 8
}
}
var err error
if r.bytesLeft == 0 {
err = io.EOF
}
return i, err
}