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 }