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 }