root.go 16 KB

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