root.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. package main
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "io/ioutil"
  7. "log"
  8. "net/http"
  9. _ "net/http/pprof"
  10. "os"
  11. "path/filepath"
  12. "plugin"
  13. "regexp"
  14. "runtime/pprof"
  15. "strings"
  16. _ "unsafe" // for go:linkname
  17. "github.com/Masterminds/sprig"
  18. "github.com/gogo/protobuf/proto"
  19. "github.com/mitchellh/go-homedir"
  20. "github.com/spf13/cobra"
  21. "github.com/spf13/pflag"
  22. "golang.org/x/crypto/ssh/terminal"
  23. progress "gopkg.in/cheggaaa/pb.v1"
  24. "gopkg.in/src-d/go-billy-siva.v4"
  25. "gopkg.in/src-d/go-billy.v4/memfs"
  26. "gopkg.in/src-d/go-billy.v4/osfs"
  27. "gopkg.in/src-d/go-git.v4"
  28. "gopkg.in/src-d/go-git.v4/plumbing/cache"
  29. "gopkg.in/src-d/go-git.v4/plumbing/object"
  30. "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh"
  31. "gopkg.in/src-d/go-git.v4/storage"
  32. "gopkg.in/src-d/go-git.v4/storage/filesystem"
  33. "gopkg.in/src-d/go-git.v4/storage/memory"
  34. "gopkg.in/src-d/hercules.v5"
  35. "gopkg.in/src-d/hercules.v5/internal/pb"
  36. )
  37. // oneLineWriter splits the output data by lines and outputs one on top of another using '\r'.
  38. // It also does some dark magic to handle Git statuses.
  39. type oneLineWriter struct {
  40. Writer io.Writer
  41. }
  42. func (writer oneLineWriter) Write(p []byte) (n int, err error) {
  43. if p[len(p)-1] == '\n' {
  44. p = p[:len(p)-1]
  45. if len(p) > 5 && bytes.Compare(p[len(p)-5:], []byte("done.")) == 0 {
  46. p = []byte("cloning...")
  47. }
  48. p = append(p, '\r')
  49. writer.Writer.Write([]byte("\r" + strings.Repeat(" ", 80) + "\r"))
  50. }
  51. n, err = writer.Writer.Write(p)
  52. return
  53. }
  54. func loadSSHIdentity(sshIdentity string) (*ssh.PublicKeys, error) {
  55. actual, err := homedir.Expand(sshIdentity)
  56. if err != nil {
  57. return nil, err
  58. }
  59. return ssh.NewPublicKeysFromFile("git", actual, "")
  60. }
  61. func loadRepository(uri string, cachePath string, disableStatus bool, sshIdentity string) *git.Repository {
  62. var repository *git.Repository
  63. var backend storage.Storer
  64. var err error
  65. if strings.Contains(uri, "://") || regexp.MustCompile("^[A-Za-z]\\w*@[A-Za-z0-9][\\w.]*:").MatchString(uri) {
  66. if cachePath != "" {
  67. backend = filesystem.NewStorage(osfs.New(cachePath), cache.NewObjectLRUDefault())
  68. _, err = os.Stat(cachePath)
  69. if !os.IsNotExist(err) {
  70. log.Printf("warning: deleted %s\n", cachePath)
  71. os.RemoveAll(cachePath)
  72. }
  73. } else {
  74. backend = memory.NewStorage()
  75. }
  76. cloneOptions := &git.CloneOptions{URL: uri}
  77. if !disableStatus {
  78. fmt.Fprint(os.Stderr, "connecting...\r")
  79. cloneOptions.Progress = oneLineWriter{Writer: os.Stderr}
  80. }
  81. if sshIdentity != "" {
  82. auth, err := loadSSHIdentity(sshIdentity)
  83. if err != nil {
  84. log.Printf("Failed loading SSH Identity %s\n", err)
  85. }
  86. cloneOptions.Auth = auth
  87. }
  88. repository, err = git.Clone(backend, nil, cloneOptions)
  89. if !disableStatus {
  90. fmt.Fprint(os.Stderr, strings.Repeat(" ", 80)+"\r")
  91. }
  92. } else if stat, err2 := os.Stat(uri); err2 == nil && !stat.IsDir() {
  93. localFs := osfs.New(filepath.Dir(uri))
  94. tmpFs := memfs.New()
  95. basePath := filepath.Base(uri)
  96. fs, err2 := sivafs.NewFilesystem(localFs, basePath, tmpFs)
  97. if err2 != nil {
  98. log.Panicf("unable to create a siva filesystem from %s: %v", uri, err2)
  99. }
  100. sivaStorage := filesystem.NewStorage(fs, cache.NewObjectLRUDefault())
  101. repository, err = git.Open(sivaStorage, tmpFs)
  102. } else {
  103. if uri[len(uri)-1] == os.PathSeparator {
  104. uri = uri[:len(uri)-1]
  105. }
  106. repository, err = git.PlainOpen(uri)
  107. }
  108. if err != nil {
  109. log.Panicf("failed to open %s: %v", uri, err)
  110. }
  111. return repository
  112. }
  113. type arrayPluginFlags map[string]bool
  114. func (apf *arrayPluginFlags) String() string {
  115. var list []string
  116. for key := range *apf {
  117. list = append(list, key)
  118. }
  119. return strings.Join(list, ", ")
  120. }
  121. func (apf *arrayPluginFlags) Set(value string) error {
  122. (*apf)[value] = true
  123. return nil
  124. }
  125. func (apf *arrayPluginFlags) Type() string {
  126. return "path"
  127. }
  128. func loadPlugins() {
  129. pluginFlags := arrayPluginFlags{}
  130. fs := pflag.NewFlagSet(os.Args[0], pflag.ContinueOnError)
  131. fs.SetOutput(ioutil.Discard)
  132. pluginFlagName := "plugin"
  133. const pluginDesc = "Load the specified plugin by the full or relative path. " +
  134. "Can be specified multiple times."
  135. fs.Var(&pluginFlags, pluginFlagName, pluginDesc)
  136. pflag.Var(&pluginFlags, pluginFlagName, pluginDesc)
  137. fs.Parse(os.Args[1:])
  138. for path := range pluginFlags {
  139. _, err := plugin.Open(path)
  140. if err != nil {
  141. log.Printf("Failed to load plugin from %s %s\n", path, err)
  142. }
  143. }
  144. }
  145. // rootCmd represents the base command when called without any subcommands
  146. var rootCmd = &cobra.Command{
  147. Use: "hercules",
  148. Short: "Analyse a Git repository.",
  149. Long: `Hercules is a flexible and fast Git repository analysis engine. The base command executes
  150. the commit processing pipeline which is automatically generated from the dependencies of one
  151. or several analysis targets. The list of the available targets is printed in --help. External
  152. targets can be added using the --plugin system.`,
  153. Args: cobra.RangeArgs(1, 2),
  154. Run: func(cmd *cobra.Command, args []string) {
  155. flags := cmd.Flags()
  156. firstParent, _ := flags.GetBool("first-parent")
  157. commitsFile, _ := flags.GetString("commits")
  158. protobuf, _ := flags.GetBool("pb")
  159. profile, _ := flags.GetBool("profile")
  160. disableStatus, _ := flags.GetBool("quiet")
  161. sshIdentity, _ := flags.GetString("ssh-identity")
  162. if profile {
  163. go http.ListenAndServe("localhost:6060", nil)
  164. prof, _ := os.Create("hercules.pprof")
  165. pprof.StartCPUProfile(prof)
  166. defer pprof.StopCPUProfile()
  167. }
  168. uri := args[0]
  169. cachePath := ""
  170. if len(args) == 2 {
  171. cachePath = args[1]
  172. }
  173. repository := loadRepository(uri, cachePath, disableStatus, sshIdentity)
  174. // core logic
  175. pipeline := hercules.NewPipeline(repository)
  176. pipeline.SetFeaturesFromFlags()
  177. var bar *progress.ProgressBar
  178. if !disableStatus {
  179. pipeline.OnProgress = func(commit, length int) {
  180. if bar == nil {
  181. bar = progress.New(length)
  182. bar.Callback = func(msg string) {
  183. os.Stderr.WriteString("\r" + msg)
  184. }
  185. bar.NotPrint = true
  186. bar.ShowPercent = false
  187. bar.ShowSpeed = false
  188. bar.SetMaxWidth(80)
  189. bar.Start()
  190. }
  191. if commit == length-1 {
  192. bar.Finish()
  193. fmt.Fprint(os.Stderr, "\r"+strings.Repeat(" ", 80)+"\rfinalizing...")
  194. } else {
  195. bar.Set(commit)
  196. }
  197. }
  198. }
  199. var commits []*object.Commit
  200. var err error
  201. if commitsFile == "" {
  202. fmt.Fprint(os.Stderr, "git log...\r")
  203. commits, err = pipeline.Commits(firstParent)
  204. } else {
  205. commits, err = hercules.LoadCommitsFromFile(commitsFile, repository)
  206. }
  207. if err != nil {
  208. log.Panicf("failed to list the commits: %v", err)
  209. }
  210. cmdlineFacts[hercules.ConfigPipelineCommits] = commits
  211. var deployed []hercules.LeafPipelineItem
  212. for name, valPtr := range cmdlineDeployed {
  213. if *valPtr {
  214. item := pipeline.DeployItem(hercules.Registry.Summon(name)[0])
  215. deployed = append(deployed, item.(hercules.LeafPipelineItem))
  216. }
  217. }
  218. pipeline.Initialize(cmdlineFacts)
  219. if dryRun, _ := cmdlineFacts[hercules.ConfigPipelineDryRun].(bool); dryRun {
  220. return
  221. }
  222. results, err := pipeline.Run(commits)
  223. if err != nil {
  224. panic(err)
  225. }
  226. if !disableStatus {
  227. fmt.Fprint(os.Stderr, "\r"+strings.Repeat(" ", 80)+"\r")
  228. // if not a terminal, the user will not see the output, so show the status
  229. if !terminal.IsTerminal(int(os.Stdout.Fd())) {
  230. fmt.Fprint(os.Stderr, "writing...\r")
  231. }
  232. }
  233. if !protobuf {
  234. printResults(uri, deployed, results)
  235. } else {
  236. protobufResults(uri, deployed, results)
  237. }
  238. },
  239. }
  240. func printResults(
  241. uri string, deployed []hercules.LeafPipelineItem,
  242. results map[hercules.LeafPipelineItem]interface{}) {
  243. commonResult := results[nil].(*hercules.CommonAnalysisResult)
  244. fmt.Println("hercules:")
  245. fmt.Println(" version: 3")
  246. fmt.Println(" hash:", hercules.BinaryGitHash)
  247. fmt.Println(" repository:", uri)
  248. fmt.Println(" begin_unix_time:", commonResult.BeginTime)
  249. fmt.Println(" end_unix_time:", commonResult.EndTime)
  250. fmt.Println(" commits:", commonResult.CommitsNumber)
  251. fmt.Println(" run_time:", commonResult.RunTime.Nanoseconds()/1e6)
  252. for _, item := range deployed {
  253. result := results[item]
  254. fmt.Printf("%s:\n", item.Name())
  255. if err := item.Serialize(result, false, os.Stdout); err != nil {
  256. panic(err)
  257. }
  258. }
  259. }
  260. func protobufResults(
  261. uri string, deployed []hercules.LeafPipelineItem,
  262. results map[hercules.LeafPipelineItem]interface{}) {
  263. header := pb.Metadata{
  264. Version: 2,
  265. Hash: hercules.BinaryGitHash,
  266. Repository: uri,
  267. }
  268. results[nil].(*hercules.CommonAnalysisResult).FillMetadata(&header)
  269. message := pb.AnalysisResults{
  270. Header: &header,
  271. Contents: map[string][]byte{},
  272. }
  273. for _, item := range deployed {
  274. result := results[item]
  275. buffer := &bytes.Buffer{}
  276. if err := item.Serialize(result, true, buffer); err != nil {
  277. panic(err)
  278. }
  279. message.Contents[item.Name()] = buffer.Bytes()
  280. }
  281. serialized, err := proto.Marshal(&message)
  282. if err != nil {
  283. panic(err)
  284. }
  285. os.Stdout.Write(serialized)
  286. }
  287. // animate the private function defined in Cobra
  288. //go:linkname tmpl github.com/spf13/cobra.tmpl
  289. func tmpl(w io.Writer, text string, data interface{}) error
  290. func formatUsage(c *cobra.Command) error {
  291. // the default UsageFunc() does some private magic c.mergePersistentFlags()
  292. // this should stay on top
  293. localFlags := c.LocalFlags()
  294. leaves := hercules.Registry.GetLeaves()
  295. plumbing := hercules.Registry.GetPlumbingItems()
  296. features := hercules.Registry.GetFeaturedItems()
  297. filter := map[string]bool{}
  298. for _, l := range leaves {
  299. filter[l.Flag()] = true
  300. for _, cfg := range l.ListConfigurationOptions() {
  301. filter[cfg.Flag] = true
  302. }
  303. }
  304. for _, i := range plumbing {
  305. for _, cfg := range i.ListConfigurationOptions() {
  306. filter[cfg.Flag] = true
  307. }
  308. }
  309. for key := range filter {
  310. localFlags.Lookup(key).Hidden = true
  311. }
  312. args := map[string]interface{}{
  313. "c": c,
  314. "leaves": leaves,
  315. "plumbing": plumbing,
  316. "features": features,
  317. }
  318. cobra.AddTemplateFuncs(sprig.TxtFuncMap())
  319. template := `Usage:{{if .c.Runnable}}
  320. {{.c.UseLine}}{{end}}{{if .c.HasAvailableSubCommands}}
  321. {{.c.CommandPath}} [command]{{end}}{{if gt (len .c.Aliases) 0}}
  322. Aliases:
  323. {{.c.NameAndAliases}}{{end}}{{if .c.HasExample}}
  324. Examples:
  325. {{.c.Example}}{{end}}{{if .c.HasAvailableSubCommands}}
  326. Available Commands:{{range .c.Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
  327. {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .c.HasAvailableLocalFlags}}
  328. Flags:
  329. {{range $line := .c.LocalFlags.FlagUsages | trimTrailingWhitespaces | split "\n"}}
  330. {{- $desc := splitList " " $line | last}}
  331. {{- $offset := sub ($desc | len) ($desc | trim | len)}}
  332. {{- $indent := splitList " " $line | initial | join " " | len | add 3 | add $offset | int}}
  333. {{- $wrap := sub 120 $indent | int}}
  334. {{- splitList " " $line | initial | join " "}} {{cat "!" $desc | wrap $wrap | indent $indent | substr $indent -1 | substr 2 -1}}
  335. {{end}}{{end}}
  336. Analysis Targets:{{range .leaves}}
  337. --{{rpad .Flag 40}}Runs {{.Name}} analysis.{{wrap 72 .Description | nindent 48}}{{range .ListConfigurationOptions}}
  338. --{{if .Type.String}}{{rpad (print .Flag " " .Type.String) 40}}{{else}}{{rpad .Flag 40}}{{end}}
  339. {{- $desc := dict "desc" .Description}}
  340. {{- if .Default}}{{$_ := set $desc "desc" (print .Description " The default value is " .FormatDefault ".")}}
  341. {{- end}}
  342. {{- $desc := pluck "desc" $desc | first}}
  343. {{- $desc | wrap 68 | indent 52 | substr 52 -1}}{{end}}
  344. {{end}}
  345. Plumbing Options:{{range .plumbing}}{{$name := .Name}}{{range .ListConfigurationOptions}}
  346. --{{if .Type.String}}{{rpad (print .Flag " " .Type.String " [" $name "]") 40}}{{else}}{{rpad (print .Flag " [" $name "]") 40}}
  347. {{- end}}
  348. {{- $desc := dict "desc" .Description}}
  349. {{- if .Default}}{{$_ := set $desc "desc" (print .Description " The default value is " .FormatDefault ".")}}
  350. {{- end}}
  351. {{- $desc := pluck "desc" $desc | first}}{{$desc | wrap 72 | indent 48 | substr 48 -1}}{{end}}{{end}}
  352. --feature:{{range $key, $value := .features}}
  353. {{rpad $key 42}}Enables {{range $index, $item := $value}}{{if $index}}, {{end}}{{$item.Name}}{{end}}.{{end}}{{if .c.HasAvailableInheritedFlags}}
  354. Global Flags:
  355. {{.c.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .c.HasHelpSubCommands}}
  356. Additional help topics:{{range .c.Commands}}{{if .IsAdditionalHelpTopicCommand}}
  357. {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .c.HasAvailableSubCommands}}
  358. Use "{{.c.CommandPath}} [command] --help" for more information about a command.{{end}}
  359. `
  360. err := tmpl(c.OutOrStderr(), template, args)
  361. for key := range filter {
  362. localFlags.Lookup(key).Hidden = false
  363. }
  364. if err != nil {
  365. c.Println(err)
  366. }
  367. return err
  368. }
  369. // versionCmd prints the API version and the Git commit hash
  370. var versionCmd = &cobra.Command{
  371. Use: "version",
  372. Short: "Print version information and exit.",
  373. Long: ``,
  374. Args: cobra.MaximumNArgs(0),
  375. Run: func(cmd *cobra.Command, args []string) {
  376. fmt.Printf("Version: %d\nGit: %s\n", hercules.BinaryVersion, hercules.BinaryGitHash)
  377. },
  378. }
  379. var cmdlineFacts map[string]interface{}
  380. var cmdlineDeployed map[string]*bool
  381. func init() {
  382. loadPlugins()
  383. rootCmd.MarkFlagFilename("plugin")
  384. rootFlags := rootCmd.Flags()
  385. rootFlags.String("commits", "", "Path to the text file with the "+
  386. "commit history to follow instead of the default `git log`. "+
  387. "The format is the list of hashes, each hash on a "+
  388. "separate line. The first hash is the root.")
  389. rootCmd.MarkFlagFilename("commits")
  390. rootFlags.Bool("first-parent", false, "Follow only the first parent in the commit history - "+
  391. "\"git log --first-parent\".")
  392. rootFlags.Bool("pb", false, "The output format will be Protocol Buffers instead of YAML.")
  393. rootFlags.Bool("quiet", !terminal.IsTerminal(int(os.Stdin.Fd())),
  394. "Do not print status updates to stderr.")
  395. rootFlags.Bool("profile", false, "Collect the profile to hercules.pprof.")
  396. rootFlags.String("ssh-identity", "", "Path to SSH identity file (e.g., ~/.ssh/id_rsa) to clone from an SSH remote.")
  397. cmdlineFacts, cmdlineDeployed = hercules.Registry.AddFlags(rootFlags)
  398. rootCmd.SetUsageFunc(formatUsage)
  399. rootCmd.AddCommand(versionCmd)
  400. versionCmd.SetUsageFunc(versionCmd.UsageFunc())
  401. }
  402. func main() {
  403. if err := rootCmd.Execute(); err != nil {
  404. fmt.Fprintln(os.Stderr, err)
  405. os.Exit(1)
  406. }
  407. }