| // Copyright 2023 The Fuchsia 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 submodule handles analyzing and updating git submodule states. |
| package submodule |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "log" |
| "os" |
| "path" |
| "regexp" |
| "sort" |
| "strings" |
| |
| "github.com/google/subcommands" |
| "go.fuchsia.dev/infra/cmd/submodule_update/gitutil" |
| ) |
| |
| // Submodule represents the status of a git submodule. |
| type Submodule struct { |
| // Name is the name of the submodule in jiri projects. |
| Name string `xml:"name,attr,omitempty"` |
| // Revision is the revision the submodule. |
| Revision string `xml:"revision,attr,omitempty"` |
| // Path is the relative path starting from the superproject root. |
| Path string `xml:"path,attr,omitempty"` |
| // Remote is the remote for a submodule. |
| Remote string `xml:"remote,attr,omitempty"` |
| } |
| |
| // Submodules maps Keys to Submodules. |
| type Submodules map[Key]Submodule |
| |
| // Key is a map key for a submodule. |
| type Key string |
| |
| // Key returns the unique Key for the project. |
| func (s Submodule) Key() Key { |
| return Key(s.Path) |
| } |
| |
| var /* const */ submoduleStatusRe = regexp.MustCompile(`(?m)^[+\-U\s]?([0-9a-f]{40})\s([a-zA-Z0-9_.\-\/]+).*$`) |
| |
| func gitSubmodules(g *gitutil.Git, cached bool) (Submodules, error) { |
| gitSubmoduleStatus, err := g.SubmoduleStatus(gitutil.CachedOpt(cached)) |
| if err != nil { |
| return nil, err |
| } |
| gitSubmodules := gitSubmoduleStatusToSubmodule(gitSubmoduleStatus) |
| addRemoteToSubmodules(g, gitSubmodules) |
| return gitSubmodules, nil |
| } |
| |
| func gitSubmoduleStatusToSubmodule(status string) Submodules { |
| var subModules = Submodules{} |
| subStatus := submoduleStatusRe.FindAllStringSubmatch(status, -1) |
| for _, status := range subStatus { |
| // Regex fields are |
| // - Full match (field 0) |
| // - SHA1 (field 1) |
| // - Path (field 2) |
| subM := Submodule{ |
| Path: status[2], |
| Revision: status[1], |
| } |
| subModules[subM.Key()] = subM |
| } |
| return subModules |
| } |
| |
| func addRemoteToSubmodules(g *gitutil.Git, s Submodules) error { |
| for _, subM := range s { |
| configKey := fmt.Sprintf("submodule.%s.url", subM.Path) |
| url, err := g.ConfigGetKeyFromFile(configKey, ".gitmodules") |
| if err != nil { |
| return err |
| } |
| subM.Remote = url |
| s[Key(subM.Path)] = subM |
| } |
| return nil |
| } |
| |
| func addIgnoreToSubmodulesConfig(g *gitutil.Git, s Submodule) error { |
| configKey := fmt.Sprintf("submodule.%s.ignore", s.Path) |
| return g.ConfigAddKeyToFile(configKey, ".gitmodules", "all") |
| } |
| |
| func addProjectNameToSubmodulesConfig(g *gitutil.Git, s Submodule) error { |
| configKey := fmt.Sprintf("submodule.%s.name", s.Path) |
| return g.ConfigAddKeyToFile(configKey, ".gitmodules", s.Name) |
| } |
| |
| // jiriProjectInfo defines jiri JSON format for 'project info' output. |
| type jiriProjectInfo struct { |
| Name string `json:"name"` |
| Path string `json:"path"` |
| |
| // Relative path w.r.t to root |
| RelativePath string `json:"relativePath"` |
| Remote string `json:"remote"` |
| Revision string `json:"revision"` |
| CurrentBranch string `json:"current_branch,omitempty"` |
| Branches []string `json:"branches,omitempty"` |
| Manifest string `json:"manifest,omitempty"` |
| GitSubmoduleOf string `json:"gitsubmoduleof,omitempty"` |
| } |
| |
| func jiriProjectsToSubmodule(path string) (Submodules, error) { |
| |
| jiriProjectsRaw, err := os.ReadFile(path) |
| if err != nil { |
| return nil, err |
| } |
| |
| var jiriProjects []jiriProjectInfo |
| err = json.Unmarshal(jiriProjectsRaw, &jiriProjects) |
| if err != nil { |
| return nil, err |
| } |
| |
| var subModules = Submodules{} |
| for _, project := range jiriProjects { |
| // Drop "integration" and "fuchsia" (relative path "'") and not a submodule of fuchsia |
| if project.RelativePath == "." || project.RelativePath == "integration" || project.GitSubmoduleOf != "fuchsia" { |
| continue |
| } |
| subM := Submodule{ |
| Name: project.Name, |
| Path: project.RelativePath, |
| Revision: project.Revision, |
| Remote: project.Remote, |
| } |
| subModules[subM.Key()] = subM |
| } |
| return subModules, nil |
| } |
| |
| // DiffSubmodule structure defines the difference between the status of two submodules |
| type DiffSubmodule struct { |
| Name string `json:"name,omitempty"` |
| Path string `json:"path"` |
| OldPath string `json:"old_path,omitempty"` |
| Revision string `json:"revision"` |
| OldRevision string `json:"old_revision,omitempty"` |
| Remote string `json:"remote,omitempty"` |
| } |
| |
| type diffSubmodulesByPath []DiffSubmodule |
| |
| func (p diffSubmodulesByPath) Len() int { |
| return len(p) |
| } |
| func (p diffSubmodulesByPath) Swap(i, j int) { |
| p[i], p[j] = p[j], p[i] |
| } |
| func (p diffSubmodulesByPath) Less(i, j int) bool { |
| return p[i].Path < p[j].Path |
| } |
| |
| // Diff structure enumerates the new, deleted and updated submodules when diffing between a set of submodules. |
| type Diff struct { |
| NewSubmodules []DiffSubmodule `json:"new_submodules"` |
| DeletedSubmodules []DiffSubmodule `json:"deleted_submodules"` |
| UpdatedSubmodules []DiffSubmodule `json:"updated_submodules"` |
| } |
| |
| func (d Diff) sort() Diff { |
| sort.Sort(diffSubmodulesByPath(d.NewSubmodules)) |
| sort.Sort(diffSubmodulesByPath(d.DeletedSubmodules)) |
| sort.Sort(diffSubmodulesByPath(d.UpdatedSubmodules)) |
| return d |
| } |
| |
| func deleteSubmodules(g *gitutil.Git, diff []DiffSubmodule) error { |
| if len(diff) == 0 { |
| return nil |
| } |
| var submodulePaths []string |
| for _, subM := range diff { |
| submodulePaths = append(submodulePaths, subM.Path) |
| } |
| return g.Remove(submodulePaths...) |
| } |
| |
| func addSubmodules(g *gitutil.Git, diff []DiffSubmodule) error { |
| if len(diff) == 0 { |
| return nil |
| } |
| for _, subMDiff := range diff { |
| if err := g.SubmoduleAdd(subMDiff.Remote, subMDiff.Path); err != nil { |
| return err |
| } |
| subM := Submodule{ |
| Name: subMDiff.Name, |
| Path: subMDiff.Path, |
| Remote: subMDiff.Remote, |
| } |
| // Make sure all new git submodules have project name included in config. |
| if err := addProjectNameToSubmodulesConfig(g, subM); err != nil { |
| return err |
| } |
| // Add ignore all to git submodules config to avoid project drift |
| if err := addIgnoreToSubmodulesConfig(g, subM); err != nil { |
| return err |
| } |
| } |
| // Checkout all added submodules at given revision |
| gs := *g |
| for _, subM := range diff { |
| gs.Update(gitutil.SubmoduleDirOpt(subM.Path)) |
| if err := gs.CheckoutBranch(subM.Revision, false); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func updateSubmodules(g *gitutil.Git, diff []DiffSubmodule, superprojectRoot string) error { |
| if len(diff) == 0 { |
| return nil |
| } |
| var submodulePaths []string |
| for _, subM := range diff { |
| // We need to fetch for every submodule that needs updating. |
| subMPath := path.Join(superprojectRoot, subM.Path) |
| g := gitutil.New(gitutil.RootDirOpt(subMPath)) |
| if err := g.Fetch("origin"); err != nil { |
| return err |
| } |
| submodulePaths = append(submodulePaths, subM.Path) |
| } |
| if err := g.SubmoduleUpdate(submodulePaths, gitutil.InitOpt(true)); err != nil { |
| return err |
| } |
| |
| gs := *g |
| for _, subM := range diff { |
| gs.Update(gitutil.SubmoduleDirOpt(subM.Path)) |
| if err := gs.CheckoutBranch(subM.Revision, false); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func updateCommitMessage(message string) string { |
| // Replace [roll] with [superproject] to differentiate commit message. |
| const RollPrefix = "[roll] " |
| // Only substitute if [roll] is at beginning of message. |
| if strings.Index(message, RollPrefix) == 0 { |
| return strings.Replace(message, RollPrefix, "[superproject] ", 1) |
| } |
| return message |
| } |
| |
| func updateSuperprojectSubmodules(g *gitutil.Git, diff Diff, superprojectRoot string) error { |
| if err := deleteSubmodules(g, diff.DeletedSubmodules); err != nil { |
| return err |
| } |
| if err := addSubmodules(g, diff.NewSubmodules); err != nil { |
| return err |
| } |
| if err := updateSubmodules(g, diff.UpdatedSubmodules, superprojectRoot); err != nil { |
| return err |
| } |
| |
| return nil |
| } |
| |
| // Add project name to all submodules. |
| func updateSubmodulesName(g *gitutil.Git, gitSubMs, jiriSubMs Submodules) error { |
| for key, gitSubM := range gitSubMs { |
| if _, ok := jiriSubMs[key]; ok { |
| gitSubM.Name = jiriSubMs[key].Name |
| if err := addProjectNameToSubmodulesConfig(g, gitSubM); err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| // Add ignore = all to all submodules. |
| func updateSubmodulesIgnore(g *gitutil.Git, gitSubMs, jiriSubMs Submodules) error { |
| for key, gitSubM := range gitSubMs { |
| if _, ok := jiriSubMs[key]; ok { |
| if err := addIgnoreToSubmodulesConfig(g, gitSubM); err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| func getDiff(submodules1, submodules2 Submodules) (Diff, error) { |
| diff := Diff{} |
| // Get deleted submodules |
| for key, s1 := range submodules1 { |
| if _, ok := submodules2[key]; !ok { |
| diff.DeletedSubmodules = append(diff.DeletedSubmodules, DiffSubmodule{ |
| Path: s1.Path, |
| Revision: s1.Revision, |
| Remote: s1.Remote, |
| }) |
| } |
| } |
| |
| // Get new and updated submodules |
| for key, s2 := range submodules2 { |
| if s1, ok := submodules1[key]; !ok { |
| diff.NewSubmodules = append(diff.NewSubmodules, DiffSubmodule{ |
| Name: s2.Name, |
| Path: s2.Path, |
| Revision: s2.Revision, |
| Remote: s2.Remote, |
| }) |
| } else if s1.Remote != s2.Remote { |
| // If remote has changed we need to treat it as a delete/add pair. |
| // Delete old submodule (with old remote) |
| diff.DeletedSubmodules = append(diff.DeletedSubmodules, DiffSubmodule{ |
| Path: s1.Path, |
| Revision: s1.Revision, |
| Remote: s1.Remote, |
| }) |
| // Add new submodule (with new remote) |
| diff.NewSubmodules = append(diff.NewSubmodules, DiffSubmodule{ |
| Name: s2.Name, |
| Path: s2.Path, |
| Revision: s2.Revision, |
| Remote: s2.Remote, |
| }) |
| } else if s1.Revision != s2.Revision { |
| // Revision changed, update to new revision. |
| diff.UpdatedSubmodules = append(diff.UpdatedSubmodules, DiffSubmodule{ |
| Name: s2.Name, |
| Path: s2.Path, |
| Revision: s2.Revision, |
| OldRevision: s1.Revision, |
| }) |
| } |
| } |
| return diff.sort(), nil |
| } |
| |
| func copyFile(srcPath, dstPath string) error { |
| sourceFileStat, err := os.Stat(srcPath) |
| if err != nil { |
| return err |
| } |
| |
| if !sourceFileStat.Mode().IsRegular() { |
| return fmt.Errorf("%s is not a regular file", srcPath) |
| } |
| |
| data, err := os.ReadFile(srcPath) |
| if err != nil { |
| return err |
| } |
| return os.WriteFile(dstPath, data, 0644) |
| } |
| |
| func copyCIPDEnsureToSuperproject(snapshotPaths map[string]string, destination string) error { |
| for _, srcPath := range snapshotPaths { |
| if srcPath == "" { |
| continue |
| } |
| dstPath := path.Join(destination, path.Base(srcPath)) |
| if err := copyFile(srcPath, dstPath); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| // UpdateSuperproject updates the submodules at superProjectRoot |
| // to match the jiri project state |
| func UpdateSuperproject(g *gitutil.Git, message string, jiriProjectsPath string, snapshotPaths map[string]string, outputJSONPath string, noCommit bool, superprojectRoot string) subcommands.ExitStatus { |
| |
| gitSubmodules, err := gitSubmodules(g, true) |
| if err != nil { |
| log.Printf("Error getting git submodules %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| jiriSubmodules, err := jiriProjectsToSubmodule(jiriProjectsPath) |
| if err != nil { |
| log.Printf("Error parsing jiri projects %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| if err := updateSubmodulesName(g, gitSubmodules, jiriSubmodules); err != nil { |
| log.Printf("Error adding project name to submodule config %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| if err := updateSubmodulesIgnore(g, gitSubmodules, jiriSubmodules); err != nil { |
| log.Printf("Error adding ignore=diry to submodule config %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| submoduleDiff, err := getDiff(gitSubmodules, jiriSubmodules) |
| if err != nil { |
| log.Printf("Error diffing submodules: %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| fmt.Printf("Submodule Diff:\n%+v", submoduleDiff) |
| // Export submodule diff json to output json |
| submoduleDiffJSON, err := json.MarshalIndent(submoduleDiff, "", " ") |
| if err != nil { |
| log.Printf("failed to marshal submodule diff to JSON: %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| if err := os.WriteFile(outputJSONPath, submoduleDiffJSON, 0644); err != nil { |
| log.Printf("Error writing submoduleDiffJSON to jsonoutput: %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| if err := updateSuperprojectSubmodules(g, submoduleDiff, superprojectRoot); err != nil { |
| log.Printf("Error updating superproject: %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| // Skip add files and commit for noCommit Flag |
| // auto_roller api expects unstaged changes. |
| if !noCommit { |
| if err := g.AddAllFiles(); err != nil { |
| log.Printf("Error adding files to commit %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| // Make sure there are files to commit |
| // This prevents empty commits when, for example, only fuchsia.git is updated. |
| files, err := g.FilesWithUncommittedChanges() |
| if err != nil { |
| log.Printf("Error checking for uncommitted files %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| if len(files) != 0 { |
| if err := g.CommitWithMessage(updateCommitMessage(message)); err != nil { |
| log.Printf("Error committing to superproject %s", err) |
| return subcommands.ExitFailure |
| } |
| |
| } |
| } |
| return subcommands.ExitSuccess |
| } |