From d183840dbf6d7afb70e2a54a0491f042166149b2 Mon Sep 17 00:00:00 2001 From: jolheiser Date: Fri, 21 Feb 2020 08:21:07 -0600 Subject: [PATCH] Initial commit Signed-off-by: jolheiser --- .gitignore | 5 ++ .golangci.yml | 23 +++++++++ Makefile | 86 ++++++++++++++++++++++++++++++++++ README.md | 43 +++++++++++++++++ go.mod | 9 ++++ go.sum | 24 ++++++++++ main.go | 108 +++++++++++++++++++++++++++++++++++++++++++ main_test.go | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++ parse.go | 101 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 525 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 parse.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f2918a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# GoLand +.idea + +# Binaries +/git-import* \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6d71439 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +linters: + enable: + - deadcode + - dogsled + - dupl + - errcheck + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - gofmt + - golint + - gosimple + - govet + - misspell + - prealloc + - staticcheck + - structcheck + - typecheck + - unparam + - unused + - varcheck \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16ede18 --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +DIST := dist +GO ?= go +SHASUM ?= shasum -a 256 + +ifneq ($(DRONE_TAG),) + VERSION ?= $(subst v,,$(DRONE_TAG)) + LONG_VERSION ?= $(VERSION) +else + ifneq ($(DRONE_BRANCH),) + VERSION ?= $(subst release/v,,$(DRONE_BRANCH)) + else + VERSION ?= master + endif + LONG_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') +endif + +LDFLAGS := $(LDFLAGS) -X "main.Version=$(LONG_VERSION)" + +.PHONY: build +build: + $(GO) build -ldflags '-s -w $(LDFLAGS)' + +.PHONY: lint +lint: + @hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + export BINARY="golangci-lint"; \ + curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.23.1; \ + fi + golangci-lint run --timeout 5m + +.PHONY: fmt +fmt: + $(GO) fmt ./... + +.PHONY: test +test: + $(GO) test -race ./... + +.PHONY: release +release: release-dirs check-xgo release-windows release-linux release-darwin release-copy release-compress release-check + +.PHONY: check-xgo +check-xgo: + @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + $(GO) get -u src.techknowlogick.com/xgo; \ + fi + +.PHONY: release-dirs +release-dirs: + mkdir -p $(DIST)/binaries $(DIST)/release + +.PHONY: release-windows +release-windows: + xgo -dest $(DIST)/binaries -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'windows/*' -out git-import-$(VERSION) . +ifeq ($(CI),drone) + cp /build/* $(DIST)/binaries +endif + +.PHONY: release-linux +release-linux: + xgo -dest $(DIST)/binaries -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/mips64le,linux/mips,linux/mipsle' -out git-import-$(VERSION) . +ifeq ($(CI),drone) + cp /build/* $(DIST)/binaries +endif + +.PHONY: release-darwin +release-darwin: + xgo -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '$(LDFLAGS)' -targets 'darwin/*' -out git-import-$(VERSION) . +ifeq ($(CI),drone) + cp /build/* $(DIST)/binaries +endif + +.PHONY: release-copy +release-copy: + cd $(DIST); for file in `find /build -type f -name "*"`; do cp $${file} ./release/; done; + +.PHONY: release-check +release-check: + cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done; + +.PHONY: release-compress +release-compress: + @hash gxz > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + $(GO) get -u github.com/ulikunitz/xz/cmd/gxz; \ + fi + cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && gxz -k -9 $${file}; done; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f06586b --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# git-import + +Similar to `go-import`, a way to create vanity URLs for git repository paths. + +Information on `go-import` can be found on the [golang website](https://golang.org/cmd/go/#hdr-Remote_import_paths) + +`git-import` strives to work in a similar manner +By providing a `meta` tag with appropriate information, `git-import` will clone the specified repository + +The following is the `meta` tag for this repository, hosted on https://go.jolheiser.com/git-import with [Vanity](https://gitea.com/jolheiser/vanity) + +```html + +``` + + +## SSH +`git-import` can set up SSH if applicable, however it must be ran with `GIT_SSH_COMMAND` set in order to configure the repository properly. + +## Examples + +Clone this repository +`git-import go.jolheiser.com/git-import` + +Clone this repository with SSH +`GIT_SSH_COMMAND="/usr/bin/ssh -i /home/user/.ssh/id_rsa" git-import -s go.jolheiser.com/git-import` + +Clone this repository, but clone into "import-git" +`git-import go.jolheiser.com/git-import import-git` + +Output the repository URL of this repo (without cloning) +`git-import -d go.jolheiser.com/git-import` + +Output the repository SSH URL of this repo (without cloning) +`git-import -d -s go.jolheiser.com/git-import` + +## Bonus Points + +Create a git alias for `git-import` +``` +git config --global alias.get /path/to/git-import +``` +and use like `git get go.jolheiser.com/git-import` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9765a80 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module go.jolheiser.com/git-import + +go 1.12 + +require ( + github.com/mattn/go-isatty v0.0.8 // indirect + github.com/urfave/cli/v2 v2.1.1 + go.jolheiser.com/beaver v1.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b2f030e --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo= +github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= +github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +go.jolheiser.com/beaver v1.0.1 h1:gt3aGEr5Bj4ZjDF1g8t8OYOGRCRXGaanGR9CmXUxez8= +go.jolheiser.com/beaver v1.0.1/go.mod h1:7X4F5+XOGSC3LejTShoBdqtRCnPWcnRgmYGmG3EKW8g= +go.jolheiser.com/beaver v1.1.1 h1:WCbTD76qMzsaZ9EOZh8tTrjRKUFludYWmepXakTcnPQ= +go.jolheiser.com/beaver v1.1.1/go.mod h1:InRbUdHTqDYNk0SB1GyO9nJRczk5gKOMmuwpyj6FlAM= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190710143415-6ec70d6a5542 h1:6ZQFf1D2YYDDI7eSwW8adlkkavTB9sw5I24FVtEvNUQ= +golang.org/x/sys v0.0.0-20190710143415-6ec70d6a5542/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a68b8b0 --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "errors" + "fmt" + "github.com/urfave/cli/v2" + "go.jolheiser.com/beaver" + "net/http" + "os" + "os/exec" + "strings" +) + +var Version = "develop" + +func main() { + app := cli.NewApp() + app.Name = "git-import" + app.Usage = "Import from vanity git URLs" + app.Version = Version + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "display", + Aliases: []string{"d"}, + Usage: "Display URL instead of cloning", + }, + &cli.BoolFlag{ + Name: "ssh", + Aliases: []string{"s"}, + Usage: "Use SSH if available", + }, + } + app.UseShortOptionHandling = true + app.EnableBashCompletion = true + app.Action = doImport + err := app.Run(os.Args) + if err != nil { + beaver.Error(err) + } +} + +func doImport(ctx *cli.Context) error { + if ctx.NArg() < 1 { + return errors.New("must specify an import URL") + } + importURL := ctx.Args().First() + if !strings.HasPrefix(importURL, "https") { + importURL = fmt.Sprintf("http://%s", importURL) + } + + res, err := http.Get(importURL) + if err != nil { + return fmt.Errorf("could not request URL `%s`: %v", importURL, err) + } + defer res.Body.Close() + + gitImport, err := parseMetaGitImport(res.Body) + if err != nil { + return fmt.Errorf("could not parse: %v", err) + } + + if ctx.Bool("display") { + if ctx.Bool("ssh") { + fmt.Println(gitImport.SSH) + return nil + } + fmt.Println(gitImport.HTTPS) + return nil + } + return doClone(ctx, gitImport) +} + +func doClone(ctx *cli.Context, gitImport GitImport) error { + projectName := gitImport.ProjectName + if ctx.NArg() > 1 { + projectName = ctx.Args().Get(1) + } + + cmd := exec.Command("git", "clone", gitImport.HTTPS, projectName) + if ctx.Bool("ssh") { + if gitImport.SSH == "" { + return errors.New("SSH was not provided by git-import") + } + if os.Getenv("GIT_SSH_COMMAND") == "" { + return errors.New("no environment variable found for GIT_SSH_COMMAND") + } + cmd = exec.Command("git", "clone", gitImport.SSH, projectName) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("could not clone: %v", err) + } + + if ctx.Bool("ssh") { + if err := os.Chdir(projectName); err != nil { + return fmt.Errorf("could not change to `%s` directory. Git config will not store SSH command", projectName) + } + cmd := exec.Command("git", "config", "--local", "core.sshCommand", os.Getenv("GIT_SSH_COMMAND")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("could not configure SSH: %v", err) + } + } + return nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..531f832 --- /dev/null +++ b/main_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "html/template" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +var ( + name = "repo" + https = "https://www.git.com/user/repo" + ssh = "git@git.com:user/repo" + + tpl1 = `` + tpl2 = `` + tpl3 = `` + tpl4 = `` + h1 = handle1{} + h2 = handle2{} + h3 = handle3{} + h4 = handle4{} +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} + +func TestGitImport(t *testing.T) { + + tt := []struct { + name string + handler http.Handler + ssh bool + value string + err bool + }{ + {name: "HTTPS 1", handler: h1, value: https}, + {name: "HTTPS 2", handler: h2, value: https}, + {name: "HTTPS 3", handler: h3, err: true}, + + {name: "SSH 1", handler: h1, ssh: true, value: ""}, + {name: "SSH 2", handler: h2, ssh: true, value: ssh}, + {name: "SSH 4", handler: h4, ssh: true, err: true}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "", nil) + tc.handler.ServeHTTP(rec, req) + gi, err := parseMetaGitImport(rec.Body) + if err != nil { + if tc.err { + return + } + t.Log(err) + t.FailNow() + } + if tc.err { + format := "GitImport.ProjectName: %s\nGitImport.HTTPS: %s\nGitImport.SSH: %s" + formatted := fmt.Sprintf(format, gi.ProjectName, gi.HTTPS, gi.SSH) + t.Logf("test-case should have produced an error\n%s", formatted) + t.FailNow() + } + if gi.ProjectName != name { + expected(t, name, gi.ProjectName) + } + if !tc.ssh && gi.HTTPS != tc.value { + expected(t, tc.value, gi.HTTPS) + t.FailNow() + } + if tc.ssh && gi.SSH != tc.value { + expected(t, tc.value, gi.SSH) + t.FailNow() + } + }) + } +} + +// _ _ _ +// | | | | ___| |_ __ ___ _ __ ___ +// | |_| |/ _ \ | '_ \ / _ \ '__/ __| +// | _ | __/ | |_) | __/ | \__ \ +// |_| |_|\___|_| .__/ \___|_| |___/ +// |_| + +func expected(t *testing.T, expected, got string) { + t.Logf("\nexpected: %s\n got: %s", expected, got) +} + +func serve(tplName, tpl string, res http.ResponseWriter) { + tmpl, err := template.New(tplName).Parse(tpl) + if err != nil { + fmt.Printf("could not parse template: %v", err) + } + if err := tmpl.Execute(res, nil); err != nil { + fmt.Printf("could not execute template: %v", err) + } +} + +type handle1 struct{} + +func (h1 handle1) ServeHTTP(res http.ResponseWriter, req *http.Request) { + serve("tpl1", tpl1, res) +} + +type handle2 struct{} + +func (h2 handle2) ServeHTTP(res http.ResponseWriter, req *http.Request) { + serve("tpl2", tpl2, res) +} + +type handle3 struct{} + +func (h3 handle3) ServeHTTP(res http.ResponseWriter, req *http.Request) { + serve("tpl3", tpl3, res) +} + +type handle4 struct{} + +func (h4 handle4) ServeHTTP(res http.ResponseWriter, req *http.Request) { + serve("tpl4", tpl4, res) +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..0a5eee6 --- /dev/null +++ b/parse.go @@ -0,0 +1,101 @@ +package main + +import ( + "encoding/xml" + "fmt" + "io" + "net/url" + "regexp" + "strings" +) + +var ( + ErrNoImport = fmt.Errorf("no git-import found") + + SSHRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*\@[A-Za-z][A-Za-z0-9_\.]*\:(?:\/?[A-Za-z][A-Za-z0-9_\-\.]*)*$`) +) + +type GitImport struct { + ProjectName string + HTTPS string + SSH string +} + +// charsetReader returns a reader for the given charset. +func charsetReader(charset string, input io.Reader) (io.Reader, error) { + switch strings.ToLower(charset) { + case "ascii": + return input, nil + default: + return nil, fmt.Errorf("can't decode XML document using charset %q", charset) + } +} + +// parseMetaGoImports returns meta imports from the HTML in r. +// Parsing ends at the end of the section or the beginning of the . +func parseMetaGitImport(r io.Reader) (gitImport GitImport, err error) { + d := xml.NewDecoder(r) + d.CharsetReader = charsetReader + d.Strict = false + var t xml.Token + for { + t, err = d.RawToken() + if err != nil { + if err == io.EOF { + err = ErrNoImport + } + break + } + if e, ok := t.(xml.StartElement); ok && strings.EqualFold(e.Name.Local, "body") { + err = ErrNoImport + break + } + if e, ok := t.(xml.EndElement); ok && strings.EqualFold(e.Name.Local, "head") { + err = ErrNoImport + break + } + e, ok := t.(xml.StartElement) + if !ok || !strings.EqualFold(e.Name.Local, "meta") { + continue + } + if attrValue(e.Attr, "name") != "git-import" { + continue + } + content := attrValue(e.Attr, "content") + f := strings.Fields(content) + if len(f) >= 2 { + if _, err = url.Parse(f[1]); err != nil { + err = fmt.Errorf("could not parse git-import HTTPS: %v", err) + break + } + gitImport = GitImport{ + ProjectName: f[0], + HTTPS: f[1], + } + if len(f) >= 3 { + if !SSHRegex.MatchString(f[2]) { + err = fmt.Errorf("could not parse git-import SSH: invalid connection string") + break + } + gitImport.SSH = f[2] + } + err = nil + } else { + err = fmt.Errorf("incorrect number of import arguments\n\n wanted: project_name https://www.myproject.com/repo [git@myproject.com:repo]\n got: %s", content) + } + break + } + + return +} + +// attrValue returns the attribute value for the case-insensitive key +// `name', or the empty string if nothing is found. +func attrValue(attrs []xml.Attr, name string) string { + for _, a := range attrs { + if strings.EqualFold(a.Name.Local, name) { + return a.Value + } + } + return "" +}