blob: f2323b0a5178bff62ae0f8b725a36cad2a218266 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"fmt"
"io"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"v.io/jiri"
"v.io/jiri/collect"
"v.io/jiri/gerrit"
"v.io/jiri/gitutil"
"v.io/jiri/profiles/profilescmdline"
"v.io/jiri/project"
"v.io/jiri/runutil"
"v.io/x/lib/cmdline"
)
const (
commitMessageFileName = ".gerrit_commit_message"
dependencyPathFileName = ".dependency_path"
multiPartMetaDataFileName = "multipart_index"
)
var (
autosubmitFlag bool
ccsFlag string
draftFlag bool
editFlag bool
forceFlag bool
hostFlag string
messageFlag string
commitMessageBodyFlag string
presubmitFlag string
remoteBranchFlag string
reviewersFlag string
setTopicFlag bool
topicFlag string
uncommittedFlag bool
verifyFlag bool
currentProjectFlag bool
cleanupMultiPartFlag bool
)
// Special labels stored in the commit message.
var (
// Auto submit label.
autosubmitLabelRE *regexp.Regexp = regexp.MustCompile("AutoSubmit")
// Change-Ids start with 'I' and are followed by 40 characters of hex.
changeIDRE *regexp.Regexp = regexp.MustCompile("Change-Id: (I[0123456789abcdefABCDEF]{40})")
// MultiPart messages are of the form: MultiPart: <n>/<m>
multiPartRE *regexp.Regexp = regexp.MustCompile(`(?m)^MultiPart: \d+/\d+$`)
// Presubmit test label.
// PresubmitTest: <type>
presubmitTestLabelRE *regexp.Regexp = regexp.MustCompile(`PresubmitTest:\s*(.*)`)
noChangesRE *regexp.Regexp = regexp.MustCompile(`! \[remote rejected\] HEAD -> refs/(for|drafts)/\S+ \(no new changes\)`)
)
// init carries out the package initialization.
func init() {
cmdCLMail = newCmdCLMail()
cmdCL = newCmdCL()
cmdCLCleanup.Flags.BoolVar(&forceFlag, "f", false, `Ignore unmerged changes.`)
cmdCLCleanup.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
cmdCLMail.Flags.BoolVar(&autosubmitFlag, "autosubmit", false, `Automatically submit the changelist when feasible.`)
cmdCLMail.Flags.StringVar(&ccsFlag, "cc", "", `Comma-seperated list of emails or LDAPs to cc.`)
cmdCLMail.Flags.BoolVar(&draftFlag, "d", false, `Send a draft changelist.`)
cmdCLMail.Flags.BoolVar(&editFlag, "edit", true, `Open an editor to edit the CL description.`)
cmdCLMail.Flags.StringVar(&hostFlag, "host", "", `Gerrit host to use. Defaults to gerrit host specified in manifest.`)
cmdCLMail.Flags.StringVar(&messageFlag, "m", "", `CL description.`)
cmdCLMail.Flags.StringVar(&commitMessageBodyFlag, "commit-message-body-file", "", `file containing the body of the CL description, that is, text without a ChangeID, MultiPart etc.`)
cmdCLMail.Flags.StringVar(&presubmitFlag, "presubmit", string(gerrit.PresubmitTestTypeAll),
fmt.Sprintf("The type of presubmit tests to run. Valid values: %s.", strings.Join(gerrit.PresubmitTestTypes(), ",")))
cmdCLMail.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
cmdCLMail.Flags.StringVar(&reviewersFlag, "r", "", `Comma-seperated list of emails or LDAPs to request review.`)
cmdCLMail.Flags.BoolVar(&setTopicFlag, "set-topic", true, `Set Gerrit CL topic.`)
cmdCLMail.Flags.StringVar(&topicFlag, "topic", "", `CL topic, defaults to <username>-<branchname>.`)
cmdCLMail.Flags.BoolVar(&uncommittedFlag, "check-uncommitted", true, `Check that no uncommitted changes exist.`)
cmdCLMail.Flags.BoolVar(&verifyFlag, "verify", true, `Run pre-push git hooks.`)
cmdCLMail.Flags.BoolVar(&currentProjectFlag, "current-project-only", false, `Run mail in the current project only.`)
cmdCLMail.Flags.BoolVar(&cleanupMultiPartFlag, "clean-multipart-metadata", false, `Cleanup the metadata associated with multipart CLs pertaining the MultiPart: x/y message without mailing any CLs.`)
cmdCLSync.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
}
func getCommitMessageFileName(jirix *jiri.X, branch string) (string, error) {
topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
if err != nil {
return "", err
}
return filepath.Join(topLevel, jiri.ProjectMetaDir, branch, commitMessageFileName), nil
}
func getDependencyPathFileName(jirix *jiri.X, branch string) (string, error) {
topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
if err != nil {
return "", err
}
return filepath.Join(topLevel, jiri.ProjectMetaDir, branch, dependencyPathFileName), nil
}
func getDependentCLs(jirix *jiri.X, branch string) ([]string, error) {
file, err := getDependencyPathFileName(jirix, branch)
if err != nil {
return nil, err
}
data, err := jirix.NewSeq().ReadFile(file)
var branches []string
if err != nil {
if !runutil.IsNotExist(err) {
return nil, err
}
if branch != remoteBranchFlag {
branches = []string{remoteBranchFlag}
}
} else {
branches = strings.Split(strings.TrimSpace(string(data)), "\n")
}
return branches, nil
}
// cmdCL represents the "jiri cl" command.
var cmdCL *cmdline.Command
// Use a factory to avoid an initialization loop between between the
// Runner function and the ParsedFlags field in the Command.
func newCmdCL() *cmdline.Command {
return &cmdline.Command{
Name: "cl",
Short: "Manage changelists for multiple projects",
Long: "Manage changelists for multiple projects.",
Children: []*cmdline.Command{cmdCLCleanup, cmdCLMail, cmdCLNew, cmdCLSync},
}
}
// cmdCLCleanup represents the "jiri cl cleanup" command.
//
// TODO(jsimsa): Replace this with a "submit" command that talks to
// Gerrit to submit the CL and then (optionally) removes it locally.
var cmdCLCleanup = &cmdline.Command{
Runner: jiri.RunnerFunc(runCLCleanup),
Name: "cleanup",
Short: "Clean up changelists that have been merged",
Long: `
Command "cleanup" checks that the given branches have been merged into
the corresponding remote branch. If a branch differs from the
corresponding remote branch, the command reports the difference and
stops. Otherwise, it deletes the given branches.
`,
ArgsName: "<branches>",
ArgsLong: "<branches> is a list of branches to cleanup.",
}
func cleanupCL(jirix *jiri.X, branches []string) (e error) {
git := gitutil.New(jirix.NewSeq())
originalBranch, err := git.CurrentBranchName()
if err != nil {
return err
}
stashed, err := git.Stash()
if err != nil {
return err
}
if stashed {
defer collect.Error(func() error { return git.StashPop() }, &e)
}
if err := git.CheckoutBranch(remoteBranchFlag); err != nil {
return err
}
checkoutOriginalBranch := true
defer collect.Error(func() error {
if checkoutOriginalBranch {
return git.CheckoutBranch(originalBranch)
}
return nil
}, &e)
if err := git.FetchRefspec("origin", remoteBranchFlag); err != nil {
return err
}
s := jirix.NewSeq()
for _, branch := range branches {
cleanupFn := func() error { return cleanupBranch(jirix, branch) }
if err := s.Call(cleanupFn, "Cleaning up branch: %s", branch).Done(); err != nil {
return err
}
if branch == originalBranch {
checkoutOriginalBranch = false
}
}
return nil
}
func cleanupBranch(jirix *jiri.X, branch string) error {
git := gitutil.New(jirix.NewSeq())
if err := git.CheckoutBranch(branch); err != nil {
return err
}
if !forceFlag {
trackingBranch := "origin/" + remoteBranchFlag
if err := git.Merge(trackingBranch); err != nil {
return err
}
files, err := git.ModifiedFiles(trackingBranch, branch)
if err != nil {
return err
}
if len(files) != 0 {
return fmt.Errorf("unmerged changes in\n%s", strings.Join(files, "\n"))
}
}
if err := git.CheckoutBranch(remoteBranchFlag); err != nil {
return err
}
if err := git.DeleteBranch(branch, gitutil.ForceOpt(true)); err != nil {
return err
}
reviewBranch := branch + "-REVIEW"
if git.BranchExists(reviewBranch) {
if err := git.DeleteBranch(reviewBranch, gitutil.ForceOpt(true)); err != nil {
return err
}
}
// Delete branch metadata.
topLevel, err := git.TopLevel()
if err != nil {
return err
}
s := jirix.NewSeq()
// Remove the branch from all dependency paths.
metadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir)
fileInfos, err := s.RemoveAll(filepath.Join(metadataDir, branch)).
ReadDir(metadataDir)
if err != nil {
return err
}
for _, fileInfo := range fileInfos {
if !fileInfo.IsDir() {
continue
}
file, err := getDependencyPathFileName(jirix, fileInfo.Name())
if err != nil {
return err
}
data, err := s.ReadFile(file)
if err != nil {
if !runutil.IsNotExist(err) {
return err
}
continue
}
branches := strings.Split(string(data), "\n")
for i, tmpBranch := range branches {
if branch == tmpBranch {
data := []byte(strings.Join(append(branches[:i], branches[i+1:]...), "\n"))
if err := s.WriteFile(file, data, os.FileMode(0644)).Done(); err != nil {
return err
}
break
}
}
}
return nil
}
func runCLCleanup(jirix *jiri.X, args []string) error {
if len(args) == 0 {
return jirix.UsageErrorf("cleanup requires at least one argument")
}
return cleanupCL(jirix, args)
}
// cmdCLMail represents the "jiri cl mail" command.
var cmdCLMail *cmdline.Command
// Use a factory to avoid an initialization loop between between the
// Runner function and the ParsedFlags field in the Command.
func newCmdCLMail() *cmdline.Command {
return &cmdline.Command{
Runner: jiri.RunnerFunc(runCLMail),
Name: "mail",
Short: "Mail a changelist for review",
Long: `
Command "mail" squashes all commits of a local branch into a single
"changelist" and mails this changelist to Gerrit as a single
commit. First time the command is invoked, it generates a Change-Id
for the changelist, which is appended to the commit
message. Consecutive invocations of the command use the same Change-Id
by default, informing Gerrit that the incomming commit is an update of
an existing changelist.
`,
}
}
type changeConflictError struct {
localBranch string
message string
remoteBranch string
}
func (e changeConflictError) Error() string {
result := "changelist conflicts with the remote " + e.remoteBranch + " branch\n\n"
result += "To resolve this problem, run 'git pull origin " + e.remoteBranch + ":" + e.localBranch + "',\n"
result += "resolve the conflicts identified below, and then try again.\n"
result += e.message
return result
}
type emptyChangeError struct{}
func (_ emptyChangeError) Error() string {
return "current branch has no commits"
}
type gerritError string
func (e gerritError) Error() string {
result := "sending code review failed\n\n"
result += string(e)
return result
}
type noChangeIDError struct{}
func (_ noChangeIDError) Error() string {
result := "changelist is missing a Change-ID"
return result
}
type uncommittedChangesError []string
func (e uncommittedChangesError) Error() string {
result := "uncommitted local changes in files:\n"
result += " " + strings.Join(e, "\n ")
return result
}
var defaultMessageHeader = `
# Describe your changelist, specifying what package(s) your change
# pertains to, followed by a short summary and, in case of non-trivial
# changelists, provide a detailed description.
#
# For example:
#
# rpc/stream/proxy: add publish address
#
# The listen address is not always the same as the address that external
# users need to connect to. This CL adds a new argument to proxy.New()
# to specify the published address that clients should connect to.
# FYI, you are about to submit the following local commits for review:
#
`
// currentProject returns the Project containing the current working directory.
// The current working directory must be inside JIRI_ROOT.
func currentProject(jirix *jiri.X) (project.Project, error) {
dir, err := os.Getwd()
if err != nil {
return project.Project{}, fmt.Errorf("os.Getwd() failed: %v", err)
}
// Walk up the path until we find a project at that path, or hit the jirix.Root.
// Note that we can't just compare path prefixes because of soft links.
for dir != jirix.Root && dir != string(filepath.Separator) {
p, err := project.ProjectAtPath(jirix, dir)
if err != nil {
dir = filepath.Dir(dir)
continue
}
return p, nil
}
return project.Project{}, fmt.Errorf("directory %q is not contained in a project", dir)
}
type multiPart struct {
clean, current bool
currentKey project.ProjectKey
currentBranch string
states map[project.ProjectKey]*project.ProjectState
keys project.ProjectKeys
}
// initForMultiPart determines the actions to be taken
// based on command line flags and project state.
func initForMultiPart(jirix *jiri.X) (*multiPart, error) {
mp := &multiPart{}
mp.clean = cleanupMultiPartFlag
if currentProjectFlag {
mp.current = true
return mp, nil
}
if mp.clean {
states, keys, err := projectStates(jirix, true)
if err != nil {
return nil, err
}
mp.states = states
mp.keys = keys
return mp, nil
}
states, keys, err := projectStates(jirix, false)
if err != nil {
return nil, err
}
if len(states) == 0 {
return nil, fmt.Errorf("Failed to find any projects")
}
current, err := currentProject(jirix)
if err != nil {
return nil, err
}
mp.currentKey = current.Key()
mp.currentBranch = states[mp.currentKey].CurrentBranch
if len(keys) == 1 {
filename := filepath.Join(states[keys[0]].Project.Path, jiri.ProjectMetaDir, mp.currentBranch, multiPartMetaDataFileName)
os.Remove(filename)
if mp.currentKey == states[keys[0]].Project.Key() {
mp.current = true
return mp, nil
}
}
mp.states = states
mp.keys = keys
return mp, nil
}
// projectStates returns a map with all projects that are on the same
// current branch as the current project, as well as a slice of their
// project keys sorted lexicographically. Unless "allowdirty" is true,
// an error is returned if any matching project has uncommitted changes.
// The keys are returned, sorted, to avoid the caller having to recreate
// the them by iterating over the map.
func projectStates(jirix *jiri.X, allowdirty bool) (map[project.ProjectKey]*project.ProjectState, project.ProjectKeys, error) {
git := gitutil.New(jirix.NewSeq())
branch, err := git.CurrentBranchName()
if err != nil {
return nil, nil, err
}
states, err := project.GetProjectStates(jirix, false)
if err != nil {
return nil, nil, err
}
uncommitted := []string{}
var keys project.ProjectKeys
for _, s := range states {
if s.CurrentBranch == branch {
key := s.Project.Key()
fullState, err := project.GetProjectState(jirix, key, true)
if err != nil {
return nil, nil, err
}
if !allowdirty && fullState.HasUncommitted {
uncommitted = append(uncommitted, string(key))
} else {
keys = append(keys, key)
}
}
}
if len(uncommitted) > 0 {
return nil, nil, fmt.Errorf("the following projects have uncommitted changes: %s", strings.Join(uncommitted, ", "))
}
members := map[project.ProjectKey]*project.ProjectState{}
for _, key := range keys {
members[key] = states[key]
}
if len(members) == 0 {
return nil, nil, nil
}
sort.Sort(keys)
return members, keys, nil
}
func (mp *multiPart) writeMultiPartMetadata(jirix *jiri.X) error {
total := len(mp.states)
index := 1
s := jirix.NewSeq()
for _, key := range mp.keys {
state := mp.states[key]
dir := filepath.Join(state.Project.Path, jiri.ProjectMetaDir, mp.currentBranch)
filename := filepath.Join(dir, multiPartMetaDataFileName)
if total < 2 {
os.Remove(filename)
continue
}
msg := fmt.Sprintf("MultiPart: %d/%d\n", index, total)
if err := s.MkdirAll(dir, os.FileMode(0755)).
WriteFile(filename, []byte(msg), os.FileMode(0644)).
Done(); err != nil {
return err
}
index++
}
return nil
}
func (mp *multiPart) cleanMultiPartMetadata(jirix *jiri.X) error {
s := jirix.NewSeq()
for _, state := range mp.states {
filename := filepath.Join(state.Project.Path, jiri.ProjectMetaDir, mp.currentBranch, multiPartMetaDataFileName)
ok, err := s.IsFile(filename)
if err != nil {
return err
}
if ok {
if err := s.Remove(filename).Done(); err != nil {
return err
}
}
}
return nil
}
func (mp *multiPart) commandline(excludeKey project.ProjectKey, flags []string) []string {
keyflag := "--projects="
for _, k := range mp.keys {
if k == excludeKey {
continue
}
keyflag += string(k) + ","
}
keyflag = strings.TrimSuffix(keyflag, ",")
clargs := []string{
"runp",
"--interactive",
keyflag,
}
clargs = append(clargs, "jiri", "cl", "mail", "--current-project-only=true")
return append(clargs, flags...)
}
// clMailMultiFlags extracts flags from the invocation of cl mail
// that should be passed on to the sub invocations of cl mail when
// operating across multiple repos.
// These are:
// -autosubmit, -cc, -d, -edit, -host, -m, -presubmit, remote-branch, -r,
// -set-topic, -topic, -check-uncommitted and -verify,
func clMailMultiFlags() []string {
flags := []string{}
stringFlag := func(name, value string) {
if profilescmdline.IsFlagSet(cmdCLMail.ParsedFlags, name) {
flags = append(flags, fmt.Sprintf("--%s=%s", name, value))
}
}
boolFlag := func(name string, value bool) {
if profilescmdline.IsFlagSet(cmdCLMail.ParsedFlags, name) {
flags = append(flags, fmt.Sprintf("--%s=%t", name, value))
}
}
// --edit is handled differently to other flags, if it is not
// specifically set, the default is to run the editor once
// and then reuse that message for the other parts of a multipart
// CL - that is, set -edit=false for the other repos. If edit
// is specifically set then that setting is used for all repos.
// So using --edit=true allows for a different CL message in
// each repo of a multipart CL.
if profilescmdline.IsFlagSet(cmdCLMail.ParsedFlags, "edit") {
// if --edit is set on the command line, use that value
// for all subcommands
flags = append(flags, fmt.Sprintf("--edit=%t", editFlag))
} else {
// if --edit is not set on the command line, use --edit=false
// for subcommands.
flags = append(flags, "--edit=false")
}
boolFlag("autosubmit", autosubmitFlag)
stringFlag("cc", ccsFlag)
boolFlag("d", draftFlag)
stringFlag("host", hostFlag)
stringFlag("m", messageFlag)
stringFlag("presubmit", presubmitFlag)
stringFlag("remote-branch", remoteBranchFlag)
stringFlag("r", reviewersFlag)
boolFlag("set-topic", setTopicFlag)
boolFlag("check-uncommitted", uncommittedFlag)
boolFlag("verify", verifyFlag)
return flags
}
// runCLMail is a wrapper that sets up and runs a review instance across
// multiple projects.
func runCLMail(jirix *jiri.X, _ []string) error {
mp, err := initForMultiPart(jirix)
if err != nil {
return err
}
if mp.clean {
if err := mp.cleanMultiPartMetadata(jirix); err != nil {
return err
}
return nil
}
if mp.current {
return runCLMailCurrent(jirix, []string{})
}
// multipart mode
if err := mp.writeMultiPartMetadata(jirix); err != nil {
mp.cleanMultiPartMetadata(jirix)
return err
}
if err := runCLMailCurrent(jirix, []string{}); err != nil {
return err
}
git := gitutil.New(jirix.NewSeq())
branch, err := git.CurrentBranchName()
if err != nil {
return err
}
initialMessage, err := strippedGerritCommitMessage(jirix, branch)
if err != nil {
return err
}
s := jirix.NewSeq()
tmp, err := s.TempFile("", branch+"-")
if err != nil {
return err
}
defer func() {
tmp.Close()
os.Remove(tmp.Name())
}()
if _, err := io.WriteString(tmp, initialMessage); err != nil {
return err
}
// Use Capture to make sure that all output from the subcommands is
// sent to stdout/stderr.
flags := clMailMultiFlags()
flags = append(flags, "--commit-message-body-file="+tmp.Name())
return s.Capture(jirix.Stdout(), jirix.Stderr()).Last("jiri", mp.commandline(mp.currentKey, flags)...)
}
func runCLMailCurrent(jirix *jiri.X, _ []string) error {
// Check that working dir exist on remote branch. Otherwise checking out
// remote branch will break the users working dir.
git := gitutil.New(jirix.NewSeq())
wd, err := os.Getwd()
if err != nil {
return err
}
topLevel, err := git.TopLevel()
if err != nil {
return err
}
relWd, err := filepath.Rel(topLevel, wd)
if err != nil {
return err
}
if !git.DirExistsOnBranch(relWd, remoteBranchFlag) {
return fmt.Errorf("directory %q does not exist on branch %q.\nPlease run 'jiri cl mail' from root directory of this repo.", relWd, remoteBranchFlag)
}
// Sanity checks for the <presubmitFlag> flag.
if !checkPresubmitFlag() {
return jirix.UsageErrorf("invalid value for the -presubmit flag. Valid values: %s.",
strings.Join(gerrit.PresubmitTestTypes(), ","))
}
p, err := currentProject(jirix)
if err != nil {
return err
}
host := hostFlag
if host == "" {
if p.GerritHost == "" {
return fmt.Errorf("No gerrit host found. Please use the '--host' flag, or add a 'gerrithost' attribute for project %q.", p.Name)
}
host = p.GerritHost
}
hostUrl, err := url.Parse(host)
if err != nil {
return fmt.Errorf("invalid Gerrit host %q: %v", host, err)
}
projectRemoteUrl, err := url.Parse(p.Remote)
if err != nil {
return fmt.Errorf("invalid project remote: %v", p.Remote, err)
}
gerritRemote := *hostUrl
gerritRemote.Path = projectRemoteUrl.Path
// Create and run the review.
review, err := newReview(jirix, p, gerrit.CLOpts{
Autosubmit: autosubmitFlag,
Ccs: parseEmails(ccsFlag),
Draft: draftFlag,
Edit: editFlag,
Remote: gerritRemote.String(),
Host: hostUrl,
Presubmit: gerrit.PresubmitTestType(presubmitFlag),
RemoteBranch: remoteBranchFlag,
Reviewers: parseEmails(reviewersFlag),
Verify: verifyFlag,
})
if err != nil {
return err
}
if confirmed, err := review.confirmFlagChanges(); err != nil {
return err
} else if !confirmed {
return nil
}
err = review.run()
// Ignore the error that is returned when there are no differences
// between the local and gerrit branches.
if err != nil && noChangesRE.MatchString(err.Error()) {
return nil
}
return err
}
// parseEmails input a list of comma separated tokens and outputs a
// list of email addresses. The tokens can either be email addresses
// or Google LDAPs in which case the suffix @google.com is appended to
// them to turn them into email addresses.
func parseEmails(value string) []string {
var emails []string
tokens := strings.Split(value, ",")
for _, token := range tokens {
if token == "" {
continue
}
if !strings.Contains(token, "@") {
token += "@google.com"
}
emails = append(emails, token)
}
return emails
}
// checkDependents makes sure that all CLs in the sequence of
// dependent CLs leading to (but not including) the current branch
// have been exported to Gerrit.
func checkDependents(jirix *jiri.X) (e error) {
originalBranch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
if err != nil {
return err
}
branches, err := getDependentCLs(jirix, originalBranch)
if err != nil {
return err
}
for i := 1; i < len(branches); i++ {
file, err := getCommitMessageFileName(jirix, branches[i])
if err != nil {
return err
}
if _, err := jirix.NewSeq().Stat(file); err != nil {
if !runutil.IsNotExist(err) {
return err
}
return fmt.Errorf(`Failed to export the branch %q to Gerrit because its ancestor %q has not been exported to Gerrit yet.
The following steps are needed before the operation can be retried:
$ git checkout %v
$ jiri cl mail
$ git checkout %v
# retry the original command
`, originalBranch, branches[i], branches[i], originalBranch)
}
}
return nil
}
type review struct {
jirix *jiri.X
featureBranch string
reviewBranch string
project project.Project
gerrit.CLOpts
}
func newReview(jirix *jiri.X, project project.Project, opts gerrit.CLOpts) (*review, error) {
// Sync all CLs in the sequence of dependent CLs ending in the
// current branch.
if err := syncCL(jirix); err != nil {
return nil, err
}
// Make sure that all CLs in the above sequence (possibly except for
// the current branch) have been exported to Gerrit. This is needed
// to make sure we have commit messages for all but the last CL.
//
// NOTE: The alternative here is to prompt the user for multiple
// commit messages, which seems less user friendly.
if err := checkDependents(jirix); err != nil {
return nil, err
}
branch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
if err != nil {
return nil, err
}
opts.Branch = branch
if opts.Topic == "" {
opts.Topic = fmt.Sprintf("%s-%s", os.Getenv("USER"), branch) // use <username>-<branchname> as the default
}
if opts.Presubmit == gerrit.PresubmitTestType("") {
opts.Presubmit = gerrit.PresubmitTestTypeAll // use gerrit.PresubmitTestTypeAll as the default
}
if opts.RemoteBranch == "" {
opts.RemoteBranch = "master" // use master as the default
}
return &review{
jirix: jirix,
project: project,
featureBranch: branch,
reviewBranch: branch + "-REVIEW",
CLOpts: opts,
}, nil
}
func checkPresubmitFlag() bool {
for _, t := range gerrit.PresubmitTestTypes() {
if presubmitFlag == t {
return true
}
}
return false
}
// confirmFlagChanges asks users to confirm if any of the
// presubmit and autosubmit flags changes.
func (review *review) confirmFlagChanges() (bool, error) {
file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
if err != nil {
return false, err
}
bytes, err := review.jirix.NewSeq().ReadFile(file)
if err != nil {
if runutil.IsNotExist(err) {
return true, nil
}
return false, err
}
content := string(bytes)
changes := []string{}
// Check presubmit label change.
prevPresubmitType := string(gerrit.PresubmitTestTypeAll)
matches := presubmitTestLabelRE.FindStringSubmatch(content)
if matches != nil {
prevPresubmitType = matches[1]
}
if presubmitFlag != prevPresubmitType {
changes = append(changes, fmt.Sprintf("- presubmit=%s to presubmit=%s", prevPresubmitType, presubmitFlag))
}
// Check autosubmit label change.
prevAutosubmit := autosubmitLabelRE.MatchString(content)
if autosubmitFlag != prevAutosubmit {
changes = append(changes, fmt.Sprintf("- autosubmit=%v to autosubmit=%v", prevAutosubmit, autosubmitFlag))
}
if len(changes) > 0 {
fmt.Printf("Changes:\n%s\n", strings.Join(changes, "\n"))
fmt.Print("Are you sure you want to make the above changes? y/N:")
var response string
if _, err := fmt.Scanf("%s\n", &response); err != nil || response != "y" {
return false, nil
}
}
return true, nil
}
// cleanup cleans up after the review.
func (review *review) cleanup(stashed bool) error {
git := gitutil.New(review.jirix.NewSeq())
if err := git.CheckoutBranch(review.CLOpts.Branch); err != nil {
return err
}
if git.BranchExists(review.reviewBranch) {
if err := git.DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true)); err != nil {
return err
}
}
if stashed {
if err := git.StashPop(); err != nil {
return err
}
}
return nil
}
// createReviewBranch creates a clean review branch from the remote
// branch this CL pertains to and then iterates over the sequence of
// dependent CLs leading to the current branch, creating one commit
// per CL by squashing all commits of each individual CL. The commit
// message for all but that last CL is derived from their
// <commitMessageFileName>, while the <message> argument is used as
// the commit message for the last commit.
func (review *review) createReviewBranch(message string) (e error) {
git := gitutil.New(review.jirix.NewSeq())
// Create the review branch.
if err := git.FetchRefspec("origin", review.CLOpts.RemoteBranch); err != nil {
return err
}
if git.BranchExists(review.reviewBranch) {
if err := git.DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true)); err != nil {
return err
}
}
upstream := "origin/" + review.CLOpts.RemoteBranch
if err := git.CreateBranchWithUpstream(review.reviewBranch, upstream); err != nil {
return err
}
if err := git.CheckoutBranch(review.reviewBranch); err != nil {
return err
}
// Register a cleanup handler in case of subsequent errors.
cleanup := true
defer collect.Error(func() error {
if !cleanup {
return git.CheckoutBranch(review.CLOpts.Branch)
}
git.CheckoutBranch(review.CLOpts.Branch, gitutil.ForceOpt(true))
git.DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true))
return nil
}, &e)
// Report an error if the CL is empty.
hasDiff, err := git.BranchesDiffer(review.CLOpts.Branch, review.reviewBranch)
if err != nil {
return err
}
if !hasDiff {
return emptyChangeError(struct{}{})
}
// If <message> is empty, replace it with the default message.
if len(message) == 0 {
var err error
message, err = review.defaultCommitMessage()
if err != nil {
return err
}
}
// Iterate over all dependent CLs leading to (and including) the
// current branch, creating one commit in the review branch per CL
// by squashing all commits of each individual CL.
branches, err := getDependentCLs(review.jirix, review.CLOpts.Branch)
if err != nil {
return err
}
branches = append(branches, review.CLOpts.Branch)
if err := review.squashBranches(branches, message); err != nil {
return err
}
cleanup = false
return nil
}
// squashBranches iterates over the given list of branches, creating
// one commit per branch in the current branch by squashing all
// commits of each individual branch.
//
// TODO(jsimsa): Consider using "git rebase --onto" to avoid having to
// deal with merge conflicts.
func (review *review) squashBranches(branches []string, message string) (e error) {
git := gitutil.New(review.jirix.NewSeq())
for i := 1; i < len(branches); i++ {
// We want to merge the <branches[i]> branch on top of the review
// branch, forcing all conflicts to be reviewed in favor of the
// <branches[i]> branch. Unfortunately, git merge does not offer a
// strategy that would do that for us. The solution implemented
// here is based on:
//
// http://stackoverflow.com/questions/173919/is-there-a-theirs-version-of-git-merge-s-ours
if err := git.Merge(branches[i], gitutil.SquashOpt(true), gitutil.StrategyOpt("ours")); err != nil {
return changeConflictError{
localBranch: branches[i],
remoteBranch: review.CLOpts.RemoteBranch,
message: err.Error(),
}
}
// Fetch the timestamp of the last commit of <branches[i]> and use
// it to create the squashed commit. This is needed to make sure
// that the commit hash of the squashed commit stays the same as
// long as the squashed sequence of commits does not change. If
// this was not the case, consecutive invocations of "jiri cl mail"
// could fail if some, but not all, of the dependent CLs submitted
// to Gerrit have changed.
output, err := git.Log(branches[i], branches[i]+"^", "%ad%n%cd")
if err != nil {
return err
}
if len(output) < 1 || len(output[0]) < 2 {
return fmt.Errorf("unexpected output length: %v", output)
}
authorDate := gitutil.AuthorDateOpt(output[0][0])
committer := gitutil.CommitterDateOpt(output[0][1])
git = gitutil.New(review.jirix.NewSeq(), authorDate, committer)
if i < len(branches)-1 {
file, err := getCommitMessageFileName(review.jirix, branches[i])
if err != nil {
return err
}
message, err := review.jirix.NewSeq().ReadFile(file)
if err != nil {
return err
}
if err := git.CommitWithMessage(string(message)); err != nil {
return err
}
} else {
committer := git.NewCommitter(review.CLOpts.Edit)
if err := committer.Commit(message); err != nil {
return err
}
}
tmpBranch := review.reviewBranch + "-" + branches[i] + "-TMP"
if err := git.CreateBranch(tmpBranch); err != nil {
return err
}
defer collect.Error(func() error {
return git.DeleteBranch(tmpBranch, gitutil.ForceOpt(true))
}, &e)
if err := git.Reset(branches[i]); err != nil {
return err
}
if err := git.Reset(tmpBranch, gitutil.ModeOpt("soft")); err != nil {
return err
}
if err := git.CommitAmend(); err != nil {
return err
}
}
return nil
}
func (review *review) readMultiPart() string {
s := review.jirix.NewSeq()
filename := filepath.Join(review.project.Path, jiri.ProjectMetaDir, review.featureBranch, multiPartMetaDataFileName)
mpart, err := s.ReadFile(filename)
if err != nil {
return ""
}
return strings.TrimSpace(string(mpart))
}
// strippedGerritCommitMessage returns the commit message stripped of variable
// meta-data such as multipart messages, or change IDs:
func strippedGerritCommitMessage(jirix *jiri.X, branch string) (string, error) {
filename, err := getCommitMessageFileName(jirix, branch)
if err != nil {
return "", err
}
msg, err := jirix.NewSeq().ReadFile(filename)
if err != nil {
return "", err
}
// Strip "MultiPart ..." from the commit messages.
strippedMessages := multiPartRE.ReplaceAllLiteralString(string(msg), "")
// Strip "Change-Id: ..." from the commit messages.
strippedMessages = changeIDRE.ReplaceAllLiteralString(strippedMessages, "")
return strippedMessages, nil
}
// defaultCommitMessage creates the default commit message from the
// list of commits on the branch.
func (review *review) defaultCommitMessage() (string, error) {
commitMessages := ""
var err error
if commitMessageBodyFlag != "" {
msg, tmpErr := ioutil.ReadFile(commitMessageBodyFlag)
commitMessages = string(msg)
err = tmpErr
} else {
commitMessages, err = gitutil.New(review.jirix.NewSeq()).CommitMessages(review.CLOpts.Branch, review.reviewBranch)
}
if err != nil {
return "", err
}
// Strip "MultiPart ..." from the commit messages.
strippedMessages := multiPartRE.ReplaceAllLiteralString(commitMessages, "")
// Strip "Change-Id: ..." from the commit messages.
strippedMessages = changeIDRE.ReplaceAllLiteralString(strippedMessages, "")
// Add comment markers (#) to every line.
commentedMessages := "# " + strings.Replace(strippedMessages, "\n", "\n# ", -1)
message := defaultMessageHeader + commentedMessages
if multipart := review.readMultiPart(); multipart != "" {
message = message + "\n" + multipart + "\n"
}
return message, nil
}
// ensureChangeID makes sure that the last commit contains a Change-Id, and
// returns an error if it does not.
func (review *review) ensureChangeID() error {
latestCommitMessage, err := gitutil.New(review.jirix.NewSeq()).LatestCommitMessage()
if err != nil {
return err
}
changeID := changeIDRE.FindString(latestCommitMessage)
if changeID == "" {
return noChangeIDError(struct{}{})
}
return nil
}
// processLabelsAndCommitFile adds/removes labels for the given commit
// message and merges in the contents of the initial-message-file.
func (review *review) processLabelsAndCommitFile(message string) string {
// Find the Change-ID and MultiPart lines.
changeIDLine := changeIDRE.FindString(message)
multiPartLine := multiPartRE.FindString(message)
if commitMessageBodyFlag != "" {
if msg, err := ioutil.ReadFile(commitMessageBodyFlag); err == nil {
message = string(msg)
}
}
// Strip existing labels and change-ID.
message = autosubmitLabelRE.ReplaceAllLiteralString(message, "")
message = presubmitTestLabelRE.ReplaceAllLiteralString(message, "")
message = changeIDRE.ReplaceAllLiteralString(message, "")
message = multiPartRE.ReplaceAllLiteralString(message, "")
// Insert labels and change-ID back.
if review.CLOpts.Autosubmit {
message += fmt.Sprintf("AutoSubmit\n")
}
if review.CLOpts.Presubmit != gerrit.PresubmitTestTypeAll {
message += fmt.Sprintf("PresubmitTest: %s\n", review.CLOpts.Presubmit)
}
if multiPartLine != "" && !strings.HasSuffix(message, "\n") {
message += "\n"
} else {
if multipart := review.readMultiPart(); multipart != "" {
if !strings.HasSuffix(message, "\n") {
message += "\n"
}
multiPartLine = multipart
}
}
message += multiPartLine
if changeIDLine != "" && !strings.HasSuffix(message, "\n") {
message += "\n"
}
message += changeIDLine
return message
}
// run implements checks that the review passes all local checks
// and then mails it to Gerrit.
func (review *review) run() (e error) {
git := gitutil.New(review.jirix.NewSeq())
if uncommittedFlag {
changes, err := git.FilesWithUncommittedChanges()
if err != nil {
return err
}
if len(changes) != 0 {
return uncommittedChangesError(changes)
}
}
if review.CLOpts.Branch == remoteBranchFlag {
return fmt.Errorf("cannot do a review from the %q branch.", remoteBranchFlag)
}
stashed, err := git.Stash()
if err != nil {
return err
}
defer collect.Error(func() error { return review.cleanup(stashed) }, &e)
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("Getwd() failed: %v", err)
}
topLevel, err := git.TopLevel()
if err != nil {
return err
}
s := review.jirix.NewSeq()
if err := s.Chdir(topLevel).Done(); err != nil {
return err
}
defer collect.Error(func() error { return review.jirix.NewSeq().Chdir(wd).Done() }, &e)
file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
if err != nil {
return err
}
message := messageFlag
if message == "" {
// Message was not passed in flag. Attempt to read it from file.
data, err := s.ReadFile(file)
if err != nil {
if !runutil.IsNotExist(err) {
return err
}
} else {
message = string(data)
}
}
// Add/remove labels to/from the commit message before asking users
// to edit it. We do this only when this is not the initial commit
// where the message is empty.
//
// For the initial commit, the labels will be processed after the
// message is edited by users, which happens in the
// updateReviewMessage method.
if message != "" {
message = review.processLabelsAndCommitFile(message)
}
if err := review.createReviewBranch(message); err != nil {
return err
}
if err := review.updateReviewMessage(file); err != nil {
return err
}
if err := review.send(); err != nil {
return err
}
if setTopicFlag {
if err := review.setTopic(); err != nil {
return err
}
}
return nil
}
// send mails the current branch out for review.
func (review *review) send() error {
if err := review.ensureChangeID(); err != nil {
return err
}
if err := gerrit.Push(review.jirix.NewSeq(), review.CLOpts); err != nil {
return gerritError(err.Error())
}
return nil
}
// getChangeID reads the commit message and extracts the change-Id
func (review *review) getChangeID() (string, error) {
file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
if err != nil {
return "", err
}
bytes, err := review.jirix.NewSeq().ReadFile(file)
if err != nil {
return "", err
}
changeID := changeIDRE.FindSubmatch(bytes)
if changeID == nil || len(changeID) < 2 {
return "", fmt.Errorf("could not find Change-Id in:\n%s", bytes)
}
return string(changeID[1]), nil
}
// setTopic sets the topic for the CL corresponding to the branch the
// review was created for.
func (review *review) setTopic() error {
changeID, err := review.getChangeID()
if err != nil {
return err
}
host := review.CLOpts.Host
if host.Scheme != "http" && host.Scheme != "https" {
return fmt.Errorf("Cannot set topic for gerrit host %q. Please use a host url with 'https' scheme or run with '--set-topic=false'.", host.String())
}
if err := review.jirix.Gerrit(host).SetTopic(changeID, review.CLOpts); err != nil {
return fmt.Errorf("failed to set topic for %v, %#v: %v", changeID, review.CLOpts, err)
}
return nil
}
// updateReviewMessage writes the commit message to the given file.
func (review *review) updateReviewMessage(file string) error {
git := gitutil.New(review.jirix.NewSeq())
if err := git.CheckoutBranch(review.reviewBranch); err != nil {
return err
}
newMessage, err := git.LatestCommitMessage()
if err != nil {
return err
}
// update MultiPart metadata.
mpart := review.readMultiPart()
newMessage = multiPartRE.ReplaceAllLiteralString(newMessage, mpart)
s := review.jirix.NewSeq()
// For the initial commit where the commit message file doesn't exist,
// add/remove labels after users finish editing the commit message.
//
// This behavior is consistent with how Change-ID is added for the
// initial commit so we don't confuse users.
if _, err := s.Stat(file); err != nil {
if runutil.IsNotExist(err) {
newMessage = review.processLabelsAndCommitFile(newMessage)
if err := git.CommitAmendWithMessage(newMessage); err != nil {
return err
}
} else {
return err
}
}
topLevel, err := git.TopLevel()
if err != nil {
return err
}
newMetadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir, review.CLOpts.Branch)
if err := s.MkdirAll(newMetadataDir, os.FileMode(0755)).
WriteFile(file, []byte(newMessage), 0644).Done(); err != nil {
return err
}
return nil
}
// cmdCLNew represents the "jiri cl new" command.
var cmdCLNew = &cmdline.Command{
Runner: jiri.RunnerFunc(runCLNew),
Name: "new",
Short: "Create a new local branch for a changelist",
Long: fmt.Sprintf(`
Command "new" creates a new local branch for a changelist. In
particular, it forks a new branch with the given name from the current
branch and records the relationship between the current branch and the
new branch in the %v metadata directory. The information recorded in
the %v metadata directory tracks dependencies between CLs and is used
by the "jiri cl sync" and "jiri cl mail" commands.
`, jiri.ProjectMetaDir, jiri.ProjectMetaDir),
ArgsName: "<name>",
ArgsLong: "<name> is the changelist name.",
}
func runCLNew(jirix *jiri.X, args []string) error {
if got, want := len(args), 1; got != want {
return jirix.UsageErrorf("unexpected number of arguments: got %v, want %v", got, want)
}
return newCL(jirix, args)
}
func newCL(jirix *jiri.X, args []string) error {
git := gitutil.New(jirix.NewSeq())
topLevel, err := git.TopLevel()
if err != nil {
return err
}
originalBranch, err := git.CurrentBranchName()
if err != nil {
return err
}
// Create a new branch using the current branch.
newBranch := args[0]
if err := git.CreateAndCheckoutBranch(newBranch); err != nil {
return err
}
// Register a cleanup handler in case of subsequent errors.
cleanup := true
defer func() {
if cleanup {
git.CheckoutBranch(originalBranch, gitutil.ForceOpt(true))
git.DeleteBranch(newBranch, gitutil.ForceOpt(true))
}
}()
s := jirix.NewSeq()
// Record the dependent CLs for the new branch. The dependent CLs
// are recorded in a <dependencyPathFileName> file as a
// newline-separated list of branch names.
branches, err := getDependentCLs(jirix, originalBranch)
if err != nil {
return err
}
branches = append(branches, originalBranch)
newMetadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir, newBranch)
if err := s.MkdirAll(newMetadataDir, os.FileMode(0755)).Done(); err != nil {
return err
}
file, err := getDependencyPathFileName(jirix, newBranch)
if err != nil {
return err
}
if err := s.WriteFile(file, []byte(strings.Join(branches, "\n")), os.FileMode(0644)).Done(); err != nil {
return err
}
cleanup = false
return nil
}
// cmdCLSync represents the "jiri cl sync" command.
var cmdCLSync = &cmdline.Command{
Runner: jiri.RunnerFunc(runCLSync),
Name: "sync",
Short: "Bring a changelist up to date",
Long: fmt.Sprintf(`
Command "sync" brings the CL identified by the current branch up to
date with the branch tracking the remote branch this CL pertains
to. To do that, the command uses the information recorded in the %v
metadata directory to identify the sequence of dependent CLs leading
to the current branch. The command then iterates over this sequence
bringing each of the CLs up to date with its ancestor. The end result
of this process is that all CLs in the sequence are up to date with
the branch that tracks the remote branch this CL pertains to.
NOTE: It is possible that the command cannot automatically merge
changes in an ancestor into its dependent. When that occurs, the
command is aborted and prints instructions that need to be followed
before the command can be retried.
`, jiri.ProjectMetaDir),
}
func runCLSync(jirix *jiri.X, _ []string) error {
return syncCL(jirix)
}
func syncCL(jirix *jiri.X) (e error) {
git := gitutil.New(jirix.NewSeq())
stashed, err := git.Stash()
if err != nil {
return err
}
if stashed {
defer collect.Error(func() error { return git.StashPop() }, &e)
}
// Register a cleanup handler in case of subsequent errors.
forceOriginalBranch := true
originalBranch, err := git.CurrentBranchName()
if err != nil {
return err
}
originalWd, err := os.Getwd()
if err != nil {
return err
}
defer func() {
if forceOriginalBranch {
git.CheckoutBranch(originalBranch, gitutil.ForceOpt(true))
}
jirix.NewSeq().Chdir(originalWd)
}()
s := jirix.NewSeq()
// Switch to an existing directory in master so we can run commands.
topLevel, err := git.TopLevel()
if err != nil {
return err
}
if err := s.Chdir(topLevel).Done(); err != nil {
return err
}
// Identify the dependents CLs leading to (and including) the
// current branch.
branches, err := getDependentCLs(jirix, originalBranch)
if err != nil {
return err
}
branches = append(branches, originalBranch)
// Sync from upstream.
if err := git.CheckoutBranch(branches[0]); err != nil {
return err
}
if err := git.Pull("origin", branches[0]); err != nil {
return err
}
// Bring all CLs in the sequence of dependent CLs leading to the
// current branch up to date with the <remoteBranchFlag> branch.
for i := 1; i < len(branches); i++ {
if err := git.CheckoutBranch(branches[i]); err != nil {
return err
}
if err := git.Merge(branches[i-1]); err != nil {
return fmt.Errorf(`Failed to automatically merge branch %v into branch %v: %v
The following steps are needed before the operation can be retried:
$ git checkout %v
$ git merge %v
# resolve all conflicts
$ git commit -a
$ git checkout %v
# retry the original operation
`, branches[i], branches[i-1], err, branches[i], branches[i-1], originalBranch)
}
}
forceOriginalBranch = false
return nil
}