go.fuchsia.dev/jiri@v0.0.0-20240502161911-b66513b29486/cmd/jiri/edit.go (about)

     1  // Copyright 2017 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"encoding/json"
     9  	"fmt"
    10  	"os"
    11  	"path"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  
    16  	"go.fuchsia.dev/jiri"
    17  	"go.fuchsia.dev/jiri/cmdline"
    18  	"go.fuchsia.dev/jiri/gitutil"
    19  	"go.fuchsia.dev/jiri/project"
    20  )
    21  
    22  type arrayFlag []string
    23  
    24  func (i *arrayFlag) String() string {
    25  	return strings.Join(*i, ", ")
    26  }
    27  
    28  func (i *arrayFlag) Set(value string) error {
    29  	*i = append(*i, value)
    30  	return nil
    31  }
    32  
    33  var editFlags struct {
    34  	projects   arrayFlag
    35  	imports    arrayFlag
    36  	packages   arrayFlag
    37  	jsonOutput string
    38  	editMode   string
    39  }
    40  
    41  const (
    42  	manifest = "manifest"
    43  	lockfile = "lockfile"
    44  	both     = "both"
    45  )
    46  
    47  type projectChanges struct {
    48  	Name   string `json:"name"`
    49  	Remote string `json:"remote"`
    50  	Path   string `json:"path"`
    51  	OldRev string `json:"old_revision"`
    52  	NewRev string `json:"new_revision"`
    53  }
    54  
    55  type importChanges struct {
    56  	Name   string `json:"name"`
    57  	Remote string `json:"remote"`
    58  	OldRev string `json:"old_revision"`
    59  	NewRev string `json:"new_revision"`
    60  }
    61  
    62  type packageChanges struct {
    63  	Name   string `json:"name"`
    64  	OldVer string `json:"old_version"`
    65  	NewVer string `json:"new_version"`
    66  }
    67  
    68  type editChanges struct {
    69  	Projects []projectChanges `json:"projects"`
    70  	Imports  []importChanges  `json:"imports"`
    71  	Packages []packageChanges `json:"packages"`
    72  }
    73  
    74  func (ec *editChanges) toFile(filename string) error {
    75  	if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
    76  		return err
    77  	}
    78  	out, err := json.MarshalIndent(ec, "", "  ")
    79  	if err != nil {
    80  		return fmt.Errorf("failed to serialize JSON output: %s\n", err)
    81  	}
    82  
    83  	err = os.WriteFile(filename, out, 0600)
    84  	if err != nil {
    85  		return fmt.Errorf("failed write JSON output to %s: %s\n", filename, err)
    86  	}
    87  
    88  	return nil
    89  }
    90  
    91  // TODO(IN-361): Make this a subcommand of 'manifest'
    92  var cmdEdit = &cmdline.Command{
    93  	Runner:   jiri.RunnerFunc(runEdit),
    94  	Name:     "edit",
    95  	Short:    "Edit manifest file",
    96  	Long:     `Edit manifest file by rolling the revision of provided projects, imports or packages`,
    97  	ArgsName: "<manifest>",
    98  	ArgsLong: "<manifest> is path of the manifest",
    99  }
   100  
   101  func init() {
   102  	flags := &cmdEdit.Flags
   103  	flags.Var(&editFlags.projects, "project", "List of projects to update. It is of form <project-name>=<revision> where revision is optional. It can be specified multiple times.")
   104  	flags.Var(&editFlags.imports, "import", "List of imports to update. It is of form <import-name>=<revision> where revision is optional. It can be specified multiple times.")
   105  	flags.Var(&editFlags.packages, "package", "List of packages to update. It is of form <package-name>=<version>. It can be specified multiple times.")
   106  	flags.StringVar(&editFlags.jsonOutput, "json-output", "", "File to print changes to, in json format.")
   107  	flags.StringVar(&editFlags.editMode, "edit-mode", "both", "Edit mode. It can be 'manifest' for updating project revisions in manifest only, 'lockfile' for updating project revisions in lockfile only or 'both' for updating project revisions in both files.")
   108  }
   109  
   110  func runEdit(jirix *jiri.X, args []string) error {
   111  	if len(args) != 1 {
   112  		return jirix.UsageErrorf("Wrong number of args")
   113  	}
   114  
   115  	editFlags.editMode = strings.ToLower(editFlags.editMode)
   116  	if editFlags.editMode != manifest && editFlags.editMode != lockfile && editFlags.editMode != both {
   117  		return fmt.Errorf("unsupported edit-mode: %q", editFlags.editMode)
   118  	}
   119  
   120  	manifestPath, err := filepath.Abs(args[0])
   121  	if err != nil {
   122  		return err
   123  	}
   124  	if len(editFlags.projects) == 0 && len(editFlags.imports) == 0 && len(editFlags.packages) == 0 {
   125  		return jirix.UsageErrorf("Please provide -project, -import and/or -package flag")
   126  	}
   127  	projects := make(map[string]string)
   128  	imports := make(map[string]string)
   129  	packages := make(map[string]string)
   130  	for _, p := range editFlags.projects {
   131  		s := strings.SplitN(p, "=", 2)
   132  		if len(s) == 1 {
   133  			projects[s[0]] = ""
   134  		} else {
   135  			projects[s[0]] = s[1]
   136  		}
   137  	}
   138  	for _, i := range editFlags.imports {
   139  		s := strings.SplitN(i, "=", 2)
   140  		if len(s) == 1 {
   141  			imports[s[0]] = ""
   142  		} else {
   143  			imports[s[0]] = s[1]
   144  		}
   145  	}
   146  	for _, p := range editFlags.packages {
   147  		// The package name may contain "=" characters; so we split the string from the rightmost "=".
   148  		separatorPos := strings.LastIndex(p, "=")
   149  		if separatorPos == -1 || separatorPos == 0 || separatorPos == len(p)-1 {
   150  			return jirix.UsageErrorf("Please provide the -package flag in the form <package-name>=<version>")
   151  		} else {
   152  			packageName := p[:separatorPos]
   153  			version := p[separatorPos+1:]
   154  			packages[packageName] = version
   155  		}
   156  	}
   157  
   158  	return updateManifest(jirix, manifestPath, projects, imports, packages)
   159  }
   160  
   161  func writeManifest(jirix *jiri.X, manifestPath, manifestContent string, projects map[string]string) error {
   162  	// Create a temp dir to save backedup lockfiles
   163  	tempDir, err := os.MkdirTemp("", "jiri_lockfile")
   164  	if err != nil {
   165  		return err
   166  	}
   167  	defer os.RemoveAll(tempDir)
   168  
   169  	// map "backup" stores the mapping between updated lockfile with backups
   170  	backup := make(map[string]string)
   171  	rewind := func() {
   172  		for k, v := range backup {
   173  			if err := os.Rename(v, k); err != nil {
   174  				jirix.Logger.Errorf("failed to revert changes to lockfile %q", k)
   175  			} else {
   176  				jirix.Logger.Debugf("reverted lockfile %q", k)
   177  			}
   178  		}
   179  	}
   180  
   181  	isLockfileDir := func(jirix *jiri.X, s string) bool {
   182  		switch s {
   183  		case "", ".", jirix.Root, string(filepath.Separator):
   184  			return false
   185  		}
   186  		return true
   187  	}
   188  
   189  	if len(projects) != 0 && (editFlags.editMode == lockfile || editFlags.editMode == both) {
   190  		// Search lockfiles and update
   191  		dir := manifestPath
   192  		for ; isLockfileDir(jirix, dir); dir = path.Dir(dir) {
   193  			lockfile := path.Join(path.Dir(dir), jirix.LockfileName)
   194  
   195  			if _, err := os.Stat(lockfile); err != nil {
   196  				jirix.Logger.Debugf("lockfile could not be accessed at %q due to error %v", lockfile, err)
   197  				continue
   198  			}
   199  			if err := updateLocks(jirix, tempDir, lockfile, backup, projects); err != nil {
   200  				rewind()
   201  				return err
   202  			}
   203  		}
   204  	}
   205  
   206  	if err := os.WriteFile(manifestPath, []byte(manifestContent), os.ModePerm); err != nil {
   207  		rewind()
   208  		return err
   209  	}
   210  	return nil
   211  }
   212  
   213  func updateLocks(jirix *jiri.X, tempDir, lockfile string, backup, projects map[string]string) error {
   214  	jirix.Logger.Debugf("try updating lockfile %q", lockfile)
   215  	bin, err := os.ReadFile(lockfile)
   216  	if err != nil {
   217  		return err
   218  	}
   219  
   220  	projectLocks, packageLocks, err := project.UnmarshalLockEntries(bin)
   221  	if err != nil {
   222  		return err
   223  	}
   224  
   225  	found := false
   226  	for k, v := range projectLocks {
   227  		if newRev, ok := projects[k.String()]; ok {
   228  			v.Revision = newRev
   229  			projectLocks[k] = v
   230  			found = true
   231  		}
   232  	}
   233  
   234  	if found {
   235  		// backup original lockfile
   236  		info, err := os.Stat(lockfile)
   237  		if err != nil {
   238  			return err
   239  		}
   240  		backupName := path.Join(tempDir, path.Base(lockfile))
   241  		if err := os.WriteFile(backupName, bin, info.Mode()); err != nil {
   242  			return err
   243  		}
   244  		backup[lockfile] = backupName
   245  		ebin, err := project.MarshalLockEntries(projectLocks, packageLocks)
   246  		if err != nil {
   247  			return err
   248  		}
   249  		jirix.Logger.Debugf("updated lockfile %q", lockfile)
   250  		return os.WriteFile(lockfile, ebin, info.Mode())
   251  	}
   252  	jirix.Logger.Debugf("skipped lockfile %q, no matching projects", lockfile)
   253  	return nil
   254  }
   255  
   256  func updateRevision(manifestContent, tag, currentRevision, newRevision, name string) (string, error) {
   257  	// We can do a trivial string replace if the `currentRevision` is non-empty
   258  	// and unique. Otherwise we need to edit the entire XML block for the project.
   259  	if currentRevision != "" && currentRevision != "HEAD" && strings.Count(manifestContent, currentRevision) == 1 {
   260  		return strings.Replace(manifestContent, currentRevision, newRevision, 1), nil
   261  	}
   262  	return updateRevisionOrVersionAttr(manifestContent, tag, newRevision, name, "revision")
   263  }
   264  
   265  func updateVersion(manifestContent, tag string, pc packageChanges) (string, error) {
   266  	// There are chances multiple packages share the same version tag,
   267  	// therefore, we cannot simple replace version string globally.
   268  	// Unlike project declaration, the version attribute of a package is not
   269  	// allowed to be empty.
   270  	name := regexp.QuoteMeta(pc.Name)
   271  	oldVal := regexp.QuoteMeta(pc.OldVer)
   272  	// Avoid using %q in regex, it behaves differently from regex.QuoteMeta.
   273  	r, err := regexp.Compile(fmt.Sprintf("( *?)<%s[\\s\\n]+[^<]*?name=\"%s\"(.|\\n)*?version=\"%s\"(.|\\n)*?\\/>", tag, name, oldVal))
   274  	if err != nil {
   275  		return "", err
   276  	}
   277  	t := r.FindStringSubmatch(manifestContent)
   278  	if t == nil {
   279  		return "", fmt.Errorf("Not able to match %s \"%s\"", tag, name)
   280  	}
   281  	s := t[0]
   282  	us := strings.Replace(s, fmt.Sprintf("version=\"%s\"", pc.OldVer), fmt.Sprintf("version=\"%s\"", pc.NewVer), 1)
   283  	return strings.Replace(manifestContent, s, us, 1), nil
   284  }
   285  
   286  func updateRevisionOrVersionAttr(manifestContent, tag, newAttrValue, name, attr string) (string, error) {
   287  	// Find the manifest fragment with the appropriate `name`.
   288  	name = regexp.QuoteMeta(name)
   289  	// Avoid using %q in regex, it behaves differently from regex.QuoteMeta.
   290  	r, err := regexp.Compile(fmt.Sprintf("( *?)<%s[\\s\\n]+[^<]*?name=\"%s\"(.|\\n)*?\\/>", tag, name))
   291  	if err != nil {
   292  		return "", err
   293  	}
   294  	t := r.FindStringSubmatch(manifestContent)
   295  	if t == nil {
   296  		return "", fmt.Errorf("Not able to match %s \"%s\"", tag, name)
   297  	}
   298  	s := t[0]
   299  	spaces := t[1]
   300  	for i := 0; i < len(tag); i++ {
   301  		spaces = spaces + " "
   302  	}
   303  
   304  	// Try to find the attribute `attr` in the fragment.
   305  	r, err = regexp.Compile(fmt.Sprintf(`%s\s*=\s*"[^"]*"`, attr))
   306  	if err != nil {
   307  		return "", fmt.Errorf("error parsing attr regexp for: %v: %w", attr, err)
   308  	}
   309  
   310  	t = r.FindStringSubmatch(s)
   311  	var rs string
   312  	if len(t) == 0 {
   313  		// No such attribute, add it.
   314  		rs = strings.Replace(s, "/>", fmt.Sprintf("\n%s  %s=%q/>", spaces, attr, newAttrValue), 1)
   315  	} else {
   316  		// There is such an attribute, replace it.
   317  		rs = strings.Replace(s, t[0], fmt.Sprintf(`%s="%s"`, attr, newAttrValue), 1)
   318  	}
   319  	// Replace entire original string s with the replacement string.
   320  	return strings.Replace(manifestContent, s, rs, 1), nil
   321  }
   322  
   323  func updateManifest(jirix *jiri.X, manifestPath string, projects, imports, packages map[string]string) error {
   324  	ec := &editChanges{
   325  		Projects: []projectChanges{},
   326  		Imports:  []importChanges{},
   327  		Packages: []packageChanges{},
   328  	}
   329  
   330  	m, err := project.ManifestFromFile(jirix, manifestPath)
   331  	if err != nil {
   332  		return err
   333  	}
   334  	content, err := os.ReadFile(manifestPath)
   335  	if err != nil {
   336  		return err
   337  	}
   338  	manifestContent := string(content)
   339  	editedProjects := make(map[string]string)
   340  	scm := gitutil.New(jirix, gitutil.RootDirOpt(filepath.Dir(manifestPath)))
   341  	for _, p := range m.Projects {
   342  		newRevision := ""
   343  		if rev, ok := projects[p.Name]; !ok {
   344  			continue
   345  		} else {
   346  			newRevision = rev
   347  		}
   348  		if newRevision == "" {
   349  			branch := "main"
   350  			if p.RemoteBranch != "" {
   351  				branch = p.RemoteBranch
   352  			}
   353  			out, err := scm.LsRemote(p.Remote, fmt.Sprintf("refs/heads/%s", branch))
   354  			if err != nil {
   355  				return err
   356  			}
   357  			newRevision = strings.Fields(string(out))[0]
   358  		}
   359  		if p.Revision == newRevision {
   360  			continue
   361  		}
   362  		if editFlags.editMode == manifest || editFlags.editMode == both {
   363  			manifestContent, err = updateRevision(manifestContent, "project", p.Revision, newRevision, p.Name)
   364  			if err != nil {
   365  				return err
   366  			}
   367  		}
   368  		editedProjects[p.Key().String()] = newRevision
   369  		ec.Projects = append(ec.Projects, projectChanges{
   370  			Name:   p.Name,
   371  			Remote: p.Remote,
   372  			Path:   p.Path,
   373  			OldRev: p.Revision,
   374  			NewRev: newRevision,
   375  		})
   376  	}
   377  
   378  	for _, i := range m.Imports {
   379  		newRevision := ""
   380  		if rev, ok := imports[i.Name]; !ok {
   381  			continue
   382  		} else {
   383  			newRevision = rev
   384  		}
   385  		if newRevision == "" {
   386  			branch := "main"
   387  			if i.RemoteBranch != "" {
   388  				branch = i.RemoteBranch
   389  			}
   390  			out, err := scm.LsRemote(i.Remote, fmt.Sprintf("refs/heads/%s", branch))
   391  			if err != nil {
   392  				return err
   393  			}
   394  			newRevision = strings.Fields(string(out))[0]
   395  		}
   396  		if i.Revision == newRevision {
   397  			continue
   398  		}
   399  		manifestContent, err = updateRevision(manifestContent, "import", i.Revision, newRevision, i.Name)
   400  		if err != nil {
   401  			return err
   402  		}
   403  		ec.Imports = append(ec.Imports, importChanges{
   404  			Name:   i.Name,
   405  			Remote: i.Remote,
   406  			OldRev: i.Revision,
   407  			NewRev: newRevision,
   408  		})
   409  	}
   410  
   411  	for _, p := range m.Packages {
   412  		newVersion := ""
   413  		if ver, ok := packages[p.Name]; !ok {
   414  			continue
   415  		} else {
   416  			newVersion = ver
   417  		}
   418  		if newVersion == "" || p.Version == newVersion {
   419  			continue
   420  		}
   421  		pc := packageChanges{
   422  			Name:   p.Name,
   423  			OldVer: p.Version,
   424  			NewVer: newVersion,
   425  		}
   426  		manifestContent, err = updateVersion(manifestContent, "package", pc)
   427  		if err != nil {
   428  			return err
   429  		}
   430  		ec.Packages = append(ec.Packages, pc)
   431  	}
   432  	if editFlags.jsonOutput != "" {
   433  		if err := ec.toFile(editFlags.jsonOutput); err != nil {
   434  			return err
   435  		}
   436  	}
   437  
   438  	return writeManifest(jirix, manifestPath, manifestContent, editedProjects)
   439  }