go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/submodule_update/submodule/submodule.go (about)

     1  // Copyright 2023 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  // Package submodule handles analyzing and updating git submodule states.
     6  package submodule
     7  
     8  import (
     9  	"encoding/json"
    10  	"fmt"
    11  	"log"
    12  	"os"
    13  	"path"
    14  	"regexp"
    15  	"sort"
    16  	"strings"
    17  
    18  	"github.com/google/subcommands"
    19  	"go.fuchsia.dev/infra/cmd/submodule_update/gitutil"
    20  )
    21  
    22  // Submodule represents the status of  a git submodule.
    23  type Submodule struct {
    24  	// Name is the name of the submodule in jiri projects.
    25  	Name string `xml:"name,attr,omitempty"`
    26  	// Revision is the revision the submodule.
    27  	Revision string `xml:"revision,attr,omitempty"`
    28  	// Path is the relative path starting from the superproject root.
    29  	Path string `xml:"path,attr,omitempty"`
    30  	// Remote is the remote for a submodule.
    31  	Remote string `xml:"remote,attr,omitempty"`
    32  }
    33  
    34  // Submodules maps Keys to Submodules.
    35  type Submodules map[Key]Submodule
    36  
    37  // Key is a map key for a submodule.
    38  type Key string
    39  
    40  // Key returns the unique Key for the project.
    41  func (s Submodule) Key() Key {
    42  	return Key(s.Path)
    43  }
    44  
    45  var submoduleStatusRe = regexp.MustCompile(`(?m)^[+\-U\s]?([0-9a-f]{40})\s([a-zA-Z0-9_.\-\/]+).*$`)
    46  
    47  func gitSubmodules(g *gitutil.Git, cached bool) (Submodules, error) {
    48  	gitSubmoduleStatus, err := g.SubmoduleStatus(gitutil.CachedOpt(cached))
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  	return gitSubmoduleStatusToSubmodule(g, gitSubmoduleStatus)
    53  }
    54  
    55  func gitSubmoduleStatusToSubmodule(g *gitutil.Git, status string) (Submodules, error) {
    56  	var subModules = Submodules{}
    57  	subStatus := submoduleStatusRe.FindAllStringSubmatch(status, -1)
    58  	for _, status := range subStatus {
    59  		// Regex fields are
    60  		// - Full match (field 0)
    61  		// - SHA1 (field 1)
    62  		// - Path (field 2)
    63  		subM := Submodule{
    64  			Path:     status[2],
    65  			Revision: status[1],
    66  		}
    67  		url, err := g.ConfigGetKeyFromFile(
    68  			fmt.Sprintf("submodule.%s.url", subM.Path),
    69  			".gitmodules")
    70  		if err != nil {
    71  			return nil, err
    72  		}
    73  		subM.Remote = url
    74  		name, err := g.ConfigGetKeyFromFile(
    75  			fmt.Sprintf("submodule.%s.name", subM.Path),
    76  			".gitmodules")
    77  		if err != nil {
    78  			return nil, err
    79  		}
    80  		subM.Name = name
    81  		subModules[subM.Key()] = subM
    82  	}
    83  	return subModules, nil
    84  }
    85  
    86  func addIgnoreToSubmodulesConfig(g *gitutil.Git, s Submodule) error {
    87  	configKey := fmt.Sprintf("submodule.%s.ignore", s.Path)
    88  	return g.ConfigAddKeyToFile(configKey, ".gitmodules", "all")
    89  }
    90  
    91  func addProjectNameToSubmodulesConfig(g *gitutil.Git, s Submodule) error {
    92  	configKey := fmt.Sprintf("submodule.%s.name", s.Path)
    93  	return g.ConfigAddKeyToFile(configKey, ".gitmodules", s.Name)
    94  }
    95  
    96  // jiriProjectInfo defines jiri JSON format for 'project info' output.
    97  type jiriProjectInfo struct {
    98  	Name string `json:"name"`
    99  	Path string `json:"path"`
   100  
   101  	// Relative path w.r.t to root
   102  	RelativePath   string   `json:"relativePath"`
   103  	Remote         string   `json:"remote"`
   104  	Revision       string   `json:"revision"`
   105  	CurrentBranch  string   `json:"current_branch,omitempty"`
   106  	Branches       []string `json:"branches,omitempty"`
   107  	Manifest       string   `json:"manifest,omitempty"`
   108  	GitSubmoduleOf string   `json:"gitsubmoduleof,omitempty"`
   109  }
   110  
   111  func jiriProjectsToSubmodule(path string) (Submodules, error) {
   112  
   113  	jiriProjectsRaw, err := os.ReadFile(path)
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  
   118  	var jiriProjects []jiriProjectInfo
   119  	err = json.Unmarshal(jiriProjectsRaw, &jiriProjects)
   120  	if err != nil {
   121  		return nil, err
   122  	}
   123  
   124  	var subModules = Submodules{}
   125  	for _, project := range jiriProjects {
   126  		// If the project name is empty that means it's a source-of-truth
   127  		// submodule that's not actually known to Jiri.
   128  		if project.Name == "" && project.GitSubmoduleOf != "" {
   129  			continue
   130  		}
   131  		// Drop "integration" and "fuchsia" (relative path "'") and not a submodule of fuchsia
   132  		if project.RelativePath == "." || project.RelativePath == "integration" || project.GitSubmoduleOf != "fuchsia" {
   133  			continue
   134  		}
   135  		subM := Submodule{
   136  			Name:     project.Name,
   137  			Path:     project.RelativePath,
   138  			Revision: project.Revision,
   139  			Remote:   project.Remote,
   140  		}
   141  		subModules[subM.Key()] = subM
   142  	}
   143  	return subModules, nil
   144  }
   145  
   146  // DiffSubmodule structure defines the difference between the status of two submodules
   147  type DiffSubmodule struct {
   148  	Name        string `json:"name,omitempty"`
   149  	Path        string `json:"path"`
   150  	OldPath     string `json:"old_path,omitempty"`
   151  	Revision    string `json:"revision"`
   152  	OldRevision string `json:"old_revision,omitempty"`
   153  	Remote      string `json:"remote,omitempty"`
   154  }
   155  
   156  type diffSubmodulesByPath []DiffSubmodule
   157  
   158  func (p diffSubmodulesByPath) Len() int {
   159  	return len(p)
   160  }
   161  func (p diffSubmodulesByPath) Swap(i, j int) {
   162  	p[i], p[j] = p[j], p[i]
   163  }
   164  func (p diffSubmodulesByPath) Less(i, j int) bool {
   165  	return p[i].Path < p[j].Path
   166  }
   167  
   168  // Diff structure enumerates the new, deleted and updated submodules when diffing between a set of submodules.
   169  type Diff struct {
   170  	NewSubmodules     []DiffSubmodule `json:"new_submodules"`
   171  	DeletedSubmodules []DiffSubmodule `json:"deleted_submodules"`
   172  	UpdatedSubmodules []DiffSubmodule `json:"updated_submodules"`
   173  }
   174  
   175  func (d Diff) sort() Diff {
   176  	sort.Sort(diffSubmodulesByPath(d.NewSubmodules))
   177  	sort.Sort(diffSubmodulesByPath(d.DeletedSubmodules))
   178  	sort.Sort(diffSubmodulesByPath(d.UpdatedSubmodules))
   179  	return d
   180  }
   181  
   182  func deleteSubmodules(g *gitutil.Git, diff []DiffSubmodule) error {
   183  	if len(diff) == 0 {
   184  		return nil
   185  	}
   186  	var submodulePaths []string
   187  	for _, subM := range diff {
   188  		submodulePaths = append(submodulePaths, subM.Path)
   189  	}
   190  	return g.Remove(submodulePaths...)
   191  }
   192  
   193  func addSubmodules(g *gitutil.Git, diff []DiffSubmodule) error {
   194  	if len(diff) == 0 {
   195  		return nil
   196  	}
   197  	for _, subMDiff := range diff {
   198  		if err := g.SubmoduleAdd(subMDiff.Remote, subMDiff.Path); err != nil {
   199  			return err
   200  		}
   201  		subM := Submodule{
   202  			Name:   subMDiff.Name,
   203  			Path:   subMDiff.Path,
   204  			Remote: subMDiff.Remote,
   205  		}
   206  		// Make sure all new git submodules have project name included in config.
   207  		if err := addProjectNameToSubmodulesConfig(g, subM); err != nil {
   208  			return err
   209  		}
   210  		// Add ignore all to git submodules config to avoid project drift
   211  		if err := addIgnoreToSubmodulesConfig(g, subM); err != nil {
   212  			return err
   213  		}
   214  	}
   215  	// Checkout all added submodules at given revision
   216  	gs := *g
   217  	for _, subM := range diff {
   218  		gs.Update(gitutil.SubmoduleDirOpt(subM.Path))
   219  		if err := gs.CheckoutBranch(subM.Revision, false); err != nil {
   220  			return err
   221  		}
   222  	}
   223  	return nil
   224  }
   225  
   226  func updateSubmodules(g *gitutil.Git, diff []DiffSubmodule, superprojectRoot string) error {
   227  	if len(diff) == 0 {
   228  		return nil
   229  	}
   230  	var submodulePaths []string
   231  	for _, subM := range diff {
   232  		// We need to fetch for every submodule that needs updating.
   233  		subMPath := path.Join(superprojectRoot, subM.Path)
   234  		g := gitutil.New(gitutil.RootDirOpt(subMPath))
   235  		if err := g.Fetch("origin"); err != nil {
   236  			return err
   237  		}
   238  		submodulePaths = append(submodulePaths, subM.Path)
   239  	}
   240  	if err := g.SubmoduleUpdate(submodulePaths, gitutil.InitOpt(true)); err != nil {
   241  		return err
   242  	}
   243  
   244  	gs := *g
   245  	for _, subM := range diff {
   246  		gs.Update(gitutil.SubmoduleDirOpt(subM.Path))
   247  		if err := gs.CheckoutBranch(subM.Revision, false); err != nil {
   248  			return err
   249  		}
   250  	}
   251  	return nil
   252  }
   253  
   254  func updateCommitMessage(message string) string {
   255  	// Replace [roll] with [superproject] to differentiate commit message.
   256  	const RollPrefix = "[roll] "
   257  	// Only substitute if [roll] is at beginning of message.
   258  	if strings.Index(message, RollPrefix) == 0 {
   259  		return strings.Replace(message, RollPrefix, "[superproject] ", 1)
   260  	}
   261  	return message
   262  }
   263  
   264  func updateSuperprojectSubmodules(g *gitutil.Git, diff Diff, superprojectRoot string) error {
   265  	if err := deleteSubmodules(g, diff.DeletedSubmodules); err != nil {
   266  		return err
   267  	}
   268  	if err := addSubmodules(g, diff.NewSubmodules); err != nil {
   269  		return err
   270  	}
   271  	if err := updateSubmodules(g, diff.UpdatedSubmodules, superprojectRoot); err != nil {
   272  		return err
   273  	}
   274  
   275  	return nil
   276  }
   277  
   278  // Add project name to all submodules.
   279  func updateSubmodulesName(g *gitutil.Git, gitSubMs, jiriSubMs Submodules) error {
   280  	for key, gitSubM := range gitSubMs {
   281  		if _, ok := jiriSubMs[key]; ok {
   282  			gitSubM.Name = jiriSubMs[key].Name
   283  			if err := addProjectNameToSubmodulesConfig(g, gitSubM); err != nil {
   284  				return err
   285  			}
   286  		}
   287  	}
   288  	return nil
   289  }
   290  
   291  // Add ignore = all to all submodules.
   292  func updateSubmodulesIgnore(g *gitutil.Git, gitSubMs, jiriSubMs Submodules) error {
   293  	for key, gitSubM := range gitSubMs {
   294  		if _, ok := jiriSubMs[key]; ok {
   295  			if err := addIgnoreToSubmodulesConfig(g, gitSubM); err != nil {
   296  				return err
   297  			}
   298  		}
   299  	}
   300  	return nil
   301  }
   302  
   303  func getDiff(gitSubmodules, jiriSubmodules Submodules) (Diff, error) {
   304  	diff := Diff{}
   305  	// Get deleted submodules
   306  	for key, s1 := range gitSubmodules {
   307  		if s1.Name == "" {
   308  			// Submodules that are source-of-truth in the superproject will not
   309  			// have the `name` field set. It is only set for submodules that
   310  			// correspond to Jiri projects. Submodules that intentionally do not
   311  			// correspond to any Jiri project should not be deleted.
   312  			continue
   313  		}
   314  		if _, ok := jiriSubmodules[key]; !ok {
   315  			diff.DeletedSubmodules = append(diff.DeletedSubmodules, DiffSubmodule{
   316  				Path:     s1.Path,
   317  				Revision: s1.Revision,
   318  				Remote:   s1.Remote,
   319  			})
   320  		}
   321  	}
   322  
   323  	// Get new and updated submodules
   324  	for key, s2 := range jiriSubmodules {
   325  		if s1, ok := gitSubmodules[key]; !ok {
   326  			diff.NewSubmodules = append(diff.NewSubmodules, DiffSubmodule{
   327  				Name:     s2.Name,
   328  				Path:     s2.Path,
   329  				Revision: s2.Revision,
   330  				Remote:   s2.Remote,
   331  			})
   332  		} else if s1.Remote != s2.Remote {
   333  			// If remote has changed we need to treat it as a delete/add pair.
   334  			// Delete old submodule (with old remote)
   335  			diff.DeletedSubmodules = append(diff.DeletedSubmodules, DiffSubmodule{
   336  				Path:     s1.Path,
   337  				Revision: s1.Revision,
   338  				Remote:   s1.Remote,
   339  			})
   340  			// Add new submodule (with new remote)
   341  			diff.NewSubmodules = append(diff.NewSubmodules, DiffSubmodule{
   342  				Name:     s2.Name,
   343  				Path:     s2.Path,
   344  				Revision: s2.Revision,
   345  				Remote:   s2.Remote,
   346  			})
   347  		} else if s1.Revision != s2.Revision {
   348  			// Revision changed, update to new revision.
   349  			diff.UpdatedSubmodules = append(diff.UpdatedSubmodules, DiffSubmodule{
   350  				Name:        s2.Name,
   351  				Path:        s2.Path,
   352  				Revision:    s2.Revision,
   353  				OldRevision: s1.Revision,
   354  			})
   355  		}
   356  	}
   357  	return diff.sort(), nil
   358  }
   359  
   360  func copyFile(srcPath, dstPath string) error {
   361  	sourceFileStat, err := os.Stat(srcPath)
   362  	if err != nil {
   363  		return err
   364  	}
   365  
   366  	if !sourceFileStat.Mode().IsRegular() {
   367  		return fmt.Errorf("%s is not a regular file", srcPath)
   368  	}
   369  
   370  	data, err := os.ReadFile(srcPath)
   371  	if err != nil {
   372  		return err
   373  	}
   374  	return os.WriteFile(dstPath, data, 0644)
   375  }
   376  
   377  func copyCIPDEnsureToSuperproject(snapshotPaths map[string]string, destination string) error {
   378  	for _, srcPath := range snapshotPaths {
   379  		if srcPath == "" {
   380  			continue
   381  		}
   382  		dstPath := path.Join(destination, path.Base(srcPath))
   383  		if err := copyFile(srcPath, dstPath); err != nil {
   384  			return err
   385  		}
   386  	}
   387  	return nil
   388  }
   389  
   390  // UpdateSuperproject updates the submodules at superProjectRoot
   391  // to match the jiri project state
   392  func UpdateSuperproject(g *gitutil.Git, message string, jiriProjectsPath string, snapshotPaths map[string]string, outputJSONPath string, noCommit bool, superprojectRoot string) subcommands.ExitStatus {
   393  
   394  	gitSubmodules, err := gitSubmodules(g, true)
   395  	if err != nil {
   396  		log.Printf("Error getting git submodules %s", err)
   397  		return subcommands.ExitFailure
   398  	}
   399  
   400  	jiriSubmodules, err := jiriProjectsToSubmodule(jiriProjectsPath)
   401  	if err != nil {
   402  		log.Printf("Error parsing jiri projects %s", err)
   403  		return subcommands.ExitFailure
   404  	}
   405  
   406  	if err := updateSubmodulesName(g, gitSubmodules, jiriSubmodules); err != nil {
   407  		log.Printf("Error adding project name to submodule config %s", err)
   408  		return subcommands.ExitFailure
   409  	}
   410  
   411  	if err := updateSubmodulesIgnore(g, gitSubmodules, jiriSubmodules); err != nil {
   412  		log.Printf("Error adding ignore=diry to submodule config %s", err)
   413  		return subcommands.ExitFailure
   414  	}
   415  
   416  	submoduleDiff, err := getDiff(gitSubmodules, jiriSubmodules)
   417  	if err != nil {
   418  		log.Printf("Error diffing submodules: %s", err)
   419  		return subcommands.ExitFailure
   420  	}
   421  
   422  	fmt.Printf("Submodule Diff:\n%+v", submoduleDiff)
   423  	// Export submodule diff json to output json
   424  	submoduleDiffJSON, err := json.MarshalIndent(submoduleDiff, "", "  ")
   425  	if err != nil {
   426  		log.Printf("failed to marshal submodule diff to JSON: %s", err)
   427  		return subcommands.ExitFailure
   428  	}
   429  
   430  	if err := os.WriteFile(outputJSONPath, submoduleDiffJSON, 0644); err != nil {
   431  		log.Printf("Error writing submoduleDiffJSON to jsonoutput: %s", err)
   432  		return subcommands.ExitFailure
   433  	}
   434  
   435  	if err := updateSuperprojectSubmodules(g, submoduleDiff, superprojectRoot); err != nil {
   436  		log.Printf("Error updating superproject: %s", err)
   437  		return subcommands.ExitFailure
   438  	}
   439  
   440  	// Skip add files and commit for noCommit Flag
   441  	// auto_roller api expects unstaged changes.
   442  	if !noCommit {
   443  		if err := g.AddAllFiles(); err != nil {
   444  			log.Printf("Error adding files to commit %s", err)
   445  			return subcommands.ExitFailure
   446  		}
   447  
   448  		// Make sure there are files to commit
   449  		// This prevents empty commits when, for example, only fuchsia.git is updated.
   450  		files, err := g.FilesWithUncommittedChanges()
   451  		if err != nil {
   452  			log.Printf("Error checking for uncommitted files %s", err)
   453  			return subcommands.ExitFailure
   454  		}
   455  
   456  		if len(files) != 0 {
   457  			if err := g.CommitWithMessage(updateCommitMessage(message)); err != nil {
   458  				log.Printf("Error committing to superproject %s", err)
   459  				return subcommands.ExitFailure
   460  			}
   461  
   462  		}
   463  	}
   464  	return subcommands.ExitSuccess
   465  }