| // Copyright 2026 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 project |
| |
| import ( |
| "errors" |
| "fmt" |
| "os" |
| "path/filepath" |
| "slices" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
| |
| "go.fuchsia.dev/jiri" |
| "go.fuchsia.dev/jiri/gitutil" |
| "golang.org/x/sync/errgroup" |
| ) |
| |
| func getProjectsThatNeedWorktrees(jirix *jiri.X) (rootProj *Project, projectsThatNeedWorktrees []Project, parentProjects Projects, manifestProjects Projects, pkgs Packages, err error) { |
| parentProjects, err = LocalProjects(jirix, FullScan) |
| if err != nil { |
| return |
| } |
| |
| localManifestProjects, err := getDefaultLocalManifestProjects(jirix) |
| if err != nil { |
| return |
| } |
| |
| manifestProjects, _, pkgs, err = LoadManifestFile(jirix, jirix.JiriManifestFile(), parentProjects, localManifestProjects) |
| if err != nil { |
| return |
| } |
| |
| if err := FilterOptionalProjectsPackages(jirix, jirix.FetchingAttrs, manifestProjects, pkgs); err != nil { |
| return nil, nil, nil, nil, nil, err |
| } |
| |
| for _, proj := range manifestProjects { |
| if parentProj, ok := parentProjects[proj.Key()]; ok { |
| relPath, err := filepath.Rel(jirix.Root, parentProj.Path) |
| if err != nil { |
| return nil, nil, nil, nil, nil, err |
| } |
| if relPath == "." { |
| p := parentProj |
| rootProj = &p |
| } else { |
| projectsThatNeedWorktrees = append(projectsThatNeedWorktrees, parentProj) |
| } |
| } |
| } |
| return |
| } |
| |
| func WorktreeAdd(jirix *jiri.X, wtRoot string) error { |
| if wtRoot == "" { |
| return fmt.Errorf("worktree path is required") |
| } |
| if !filepath.IsAbs(wtRoot) { |
| wtRoot = filepath.Clean(filepath.Join(jirix.Cwd, wtRoot)) |
| } |
| |
| if !jirix.PackageCacheEnabled { |
| jirix.Logger.Warningf("package-cache is not enabled. Enabling it makes 'jiri worktree add' faster by sharing packages with the main tree.\n" + |
| "To enable it, run:\n" + |
| " jiri init -package-cache=true\n" + |
| "After enabling, run the following to migrate:\n" + |
| " jiri fetch-packages -local-manifest") |
| } |
| |
| rootProj, projectsThatNeedWorktrees, _, _, pkgs, err := getProjectsThatNeedWorktrees(jirix) |
| if err != nil { |
| return err |
| } |
| |
| jirix.Logger.Debugf("Projects that need worktrees: %d", len(projectsThatNeedWorktrees)) |
| for _, p := range projectsThatNeedWorktrees { |
| jirix.Logger.Debugf("Needing worktree: %s", p.Name) |
| } |
| |
| if rootProj == nil && len(projectsThatNeedWorktrees) == 0 { |
| return fmt.Errorf("no projects to provision") |
| } |
| |
| // Provision root project first if it exists. |
| if rootProj != nil { |
| if err := createWorktree(jirix, *rootProj, jirix.Root, wtRoot); err != nil { |
| return err |
| } |
| } else { |
| // If no root project, we still need to create the worktree root directory. |
| if err := os.MkdirAll(wtRoot, 0755); err != nil { |
| return err |
| } |
| } |
| |
| // Create .jiri_root in worktree. |
| wtJiriRoot := filepath.Join(wtRoot, jiri.RootMetaDir) |
| if err := os.MkdirAll(wtJiriRoot, 0755); err != nil { |
| return err |
| } |
| |
| // Symlink bin directory from parent. |
| parentBinDir := jirix.BinDir() |
| wtBinDir := filepath.Join(wtJiriRoot, "bin") |
| if err := os.Symlink(parentBinDir, wtBinDir); err != nil { |
| return err |
| } |
| |
| // Copy config files. |
| parentConfigPath := filepath.Join(jirix.RootMetaDir(), jiri.ConfigFile) |
| wtConfigPath := filepath.Join(wtJiriRoot, jiri.ConfigFile) |
| |
| cfg := &jiri.Config{} |
| if _, err := os.Stat(parentConfigPath); err == nil { |
| cfg, err = jiri.ConfigFromFile(parentConfigPath) |
| if err != nil { |
| return err |
| } |
| } |
| // Write parent_root to config. |
| cfg.WorktreeParentRoot = jirix.Root |
| if parentID, err := jiri.GetParentUpdateID(jirix.Root); err == nil { |
| cfg.WorktreeCurrentUpdateID = parentID |
| } |
| if err := cfg.Write(wtConfigPath); err != nil { |
| return err |
| } |
| |
| // Symlink or copy .jiri_manifest from parent. |
| parentManifest := filepath.Join(jirix.Root, jiri.JiriManifestFile) |
| wtManifest := filepath.Join(wtRoot, jiri.JiriManifestFile) |
| if fi, err := os.Lstat(parentManifest); err == nil { |
| if fi.Mode()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(parentManifest) |
| if err != nil { |
| return err |
| } |
| if err := os.Symlink(target, wtManifest); err != nil { |
| return err |
| } |
| } else { |
| content, err := os.ReadFile(parentManifest) |
| if err != nil { |
| return err |
| } |
| if err := os.WriteFile(wtManifest, content, fi.Mode().Perm()); err != nil { |
| return err |
| } |
| } |
| } |
| |
| if err := symlinkPackagesFromCache(jirix, jirix.Root, wtRoot, pkgs); err != nil { |
| return err |
| } |
| |
| // Copy update_history/latest from parent. |
| parentLatestLink := jirix.UpdateHistoryLatestLink() |
| if _, err := os.Stat(parentLatestLink); err == nil { |
| wtUpdateHistoryDir := filepath.Join(wtJiriRoot, "update_history") |
| if err := os.MkdirAll(wtUpdateHistoryDir, 0755); err != nil { |
| return err |
| } |
| wtLatestLink := filepath.Join(wtUpdateHistoryDir, "latest") |
| content, err := os.ReadFile(parentLatestLink) |
| if err != nil { |
| return err |
| } |
| if err := os.WriteFile(wtLatestLink, content, 0644); err != nil { |
| return err |
| } |
| } |
| |
| if err := createWorktrees(jirix, projectsThatNeedWorktrees, jirix.Root, wtRoot); err != nil { |
| return err |
| } |
| |
| if err := registerWorktree(jirix, wtRoot); err != nil { |
| return err |
| } |
| |
| // Sync projects and run hooks. |
| return WorktreeSync(jirix, wtRoot) |
| } |
| |
| // groupProjectsByDepth groups projects by their path depth relative to rootPath, |
| // and returns the sorted depths. |
| func groupProjectsByDepth(projects []Project, rootPath string) (map[int][]Project, []int, error) { |
| groups := make(map[int][]Project) |
| var depths []int |
| for _, proj := range projects { |
| relPath, err := filepath.Rel(rootPath, proj.Path) |
| if err != nil { |
| return nil, nil, err |
| } |
| components := strings.Split(filepath.ToSlash(relPath), "/") |
| depth := len(components) |
| if _, ok := groups[depth]; !ok { |
| depths = append(depths, depth) |
| } |
| groups[depth] = append(groups[depth], proj) |
| } |
| sort.Ints(depths) |
| return groups, depths, nil |
| } |
| |
| // runProjectsParallel runs a task function f concurrently for all projects in a slice, |
| // using jirix.Jobs concurrency limit and collecting all errors returned. |
| func runProjectsParallel(jirix *jiri.X, projects []Project, f func(Project) error) error { |
| if len(projects) == 0 { |
| return nil |
| } |
| limit := make(chan struct{}, jirix.Jobs) |
| var g errgroup.Group |
| var mu sync.Mutex |
| var errs []error |
| |
| for _, proj := range projects { |
| limit <- struct{}{} |
| p := proj |
| g.Go(func() error { |
| defer func() { <-limit }() |
| if err := f(p); err != nil { |
| mu.Lock() |
| errs = append(errs, err) |
| mu.Unlock() |
| } |
| return nil |
| }) |
| } |
| |
| g.Wait() |
| return errors.Join(errs...) |
| } |
| |
| // runProjectsParallelWithDepth groups projects by path depth relative to rootPath, |
| // and runs the task function f concurrently for each depth level sequentially |
| // (either shallower-to-deeper, or deeper-to-shallower if reverse is true). |
| func runProjectsParallelWithDepth(jirix *jiri.X, projects []Project, rootPath string, reverse bool, f func(Project) error) error { |
| groups, depths, err := groupProjectsByDepth(projects, rootPath) |
| if err != nil { |
| return err |
| } |
| |
| if reverse { |
| slices.Reverse(depths) |
| } |
| |
| for _, depth := range depths { |
| group := groups[depth] |
| if err := runProjectsParallel(jirix, group, f); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func createWorktrees(jirix *jiri.X, projects []Project, parentRoot, wtRoot string) error { |
| jirix.TimerPush("create worktrees") |
| defer jirix.TimerPop() |
| return runProjectsParallelWithDepth(jirix, projects, parentRoot, false /*reverse*/, func(p Project) error { |
| return createWorktree(jirix, p, parentRoot, wtRoot) |
| }) |
| } |
| |
| func createWorktree(jirix *jiri.X, proj Project, parentRoot, wtRoot string) error { |
| relPath, err := filepath.Rel(parentRoot, proj.Path) |
| if err != nil { |
| return err |
| } |
| wtProjPath := filepath.Join(wtRoot, relPath) |
| |
| parentProjGit := gitutil.New(jirix, gitutil.RootDirOpt(proj.Path)) |
| if err := parentProjGit.WorktreePrune(); err != nil { |
| return err |
| } |
| rev, err := parentProjGit.CurrentRevision() |
| if err != nil { |
| return err |
| } |
| |
| if err := os.MkdirAll(filepath.Dir(wtProjPath), 0755); err != nil { |
| return err |
| } |
| |
| isRootProject := relPath == "." |
| if err := parentProjGit.WorktreeAdd(wtProjPath, rev, gitutil.ForceOpt(isRootProject), gitutil.DetachOpt(true)); err != nil { |
| return fmt.Errorf("git worktree add failed for %s: %v", proj.Name, err) |
| } |
| |
| return nil |
| } |
| |
| func getWorkspaceContext(jirix *jiri.X, root string) (*jiri.X, error) { |
| if root == "" { |
| root = jirix.Root |
| } |
| if !filepath.IsAbs(root) { |
| root = filepath.Clean(filepath.Join(jirix.Cwd, root)) |
| } |
| |
| wsJirix := *jirix |
| wsJirix.Root = root |
| wsJirix.Cwd = root |
| |
| cfgPath := filepath.Join(root, jiri.RootMetaDir, jiri.ConfigFile) |
| if _, err := os.Stat(cfgPath); err == nil { |
| cfg, err := jiri.ConfigFromFile(cfgPath) |
| if err != nil { |
| return nil, err |
| } |
| wsJirix.WorktreeParentRoot = cfg.WorktreeParentRoot |
| wsJirix.WorktreeCurrentUpdateID = cfg.WorktreeCurrentUpdateID |
| } |
| return &wsJirix, nil |
| } |
| |
| func WorktreeSync(jirix *jiri.X, wtRoot string) error { |
| wtJirix, err := getWorkspaceContext(jirix, wtRoot) |
| if err != nil { |
| return err |
| } |
| jirix = wtJirix |
| |
| if jirix.WorktreeParentRoot == "" { |
| return fmt.Errorf("not in a worktree") |
| } |
| |
| worktreeProjects, err := LocalProjects(jirix, FullScan) |
| if err != nil { |
| return err |
| } |
| |
| parentManifestFile := filepath.Join(jirix.WorktreeParentRoot, jiri.JiriManifestFile) |
| localManifestProjects, err := getDefaultLocalManifestProjects(jirix) |
| if err != nil { |
| return err |
| } |
| parentProjects, hooks, pkgs, err := LoadManifestFile(jirix, parentManifestFile, worktreeProjects, localManifestProjects) |
| if err != nil { |
| return err |
| } |
| if err := FilterOptionalProjectsPackages(jirix, jirix.FetchingAttrs, parentProjects, pkgs); err != nil { |
| return err |
| } |
| |
| MatchLocalWithRemote(worktreeProjects, parentProjects) |
| |
| // Align worktree with parent's sync point (latest snapshot) by default. |
| parentJirix := *jirix |
| parentJirix.Root = jirix.WorktreeParentRoot |
| parentJirix.Cwd = jirix.WorktreeParentRoot |
| parentJirix.WorktreeParentRoot = "" |
| |
| var targetRevisions map[ProjectKey]string |
| |
| parentLatestSnapshot := filepath.Join(jirix.WorktreeParentRoot, jiri.RootMetaDir, "update_history", "latest") |
| latestSnapshotExists, err := isFile(parentLatestSnapshot) |
| if err != nil { |
| return err |
| } |
| |
| if latestSnapshotExists { |
| snapshotProjects, _, _, err := LoadSnapshotFile(jirix, parentLatestSnapshot) |
| if err != nil { |
| return err |
| } |
| targetRevisions = make(map[ProjectKey]string) |
| for key, p := range snapshotProjects { |
| targetRevisions[key] = p.Revision |
| } |
| } else { |
| parentLocalProjects, err := LocalProjects(&parentJirix, FullScan) |
| if err != nil { |
| return err |
| } |
| targetRevisions = make(map[ProjectKey]string) |
| for key, p := range parentLocalProjects { |
| targetRevisions[key] = p.Revision |
| } |
| } |
| |
| for key, pp := range parentProjects { |
| if rev, ok := targetRevisions[key]; ok { |
| pp.Revision = rev |
| parentProjects[key] = pp |
| } |
| } |
| |
| // Checkout projects in parallel. |
| if err := checkoutProjects(jirix, worktreeProjects, parentProjects); err != nil { |
| return err |
| } |
| |
| // Reload local projects after checkout to ensure correct revisions in memory. |
| worktreeProjects, err = LocalProjects(jirix, FullScan) |
| if err != nil { |
| return err |
| } |
| MatchLocalWithRemote(worktreeProjects, parentProjects) |
| |
| params := UpdateUniverseParams{ |
| RunHooks: true, |
| FetchPackages: true, |
| RunHookTimeout: DefaultHookTimeout, |
| FetchPackagesTimeout: DefaultPackageTimeout, |
| SkipStatus: true, |
| } |
| |
| if err := updateProjects(jirix, worktreeProjects, parentProjects, hooks, pkgs, false /*snapshot*/, params); err != nil { |
| return err |
| } |
| |
| cfgPath := filepath.Join(jirix.Root, jiri.RootMetaDir, jiri.ConfigFile) |
| cfg, err := jiri.ConfigFromFile(cfgPath) |
| if err == nil { |
| if parentID, err := jiri.GetParentUpdateID(jirix.WorktreeParentRoot); err == nil { |
| cfg.WorktreeCurrentUpdateID = parentID |
| if err := cfg.Write(cfgPath); err != nil { |
| jirix.Logger.Warningf("Failed to write config: %v", err) |
| } else { |
| jirix.WorktreeCurrentUpdateID = parentID |
| } |
| } |
| } |
| return nil |
| } |
| |
| func checkoutProjects(jirix *jiri.X, worktreeProjects, parentProjects Projects) error { |
| var projs []Project |
| for key, wtProj := range worktreeProjects { |
| if _, ok := parentProjects[key]; ok { |
| projs = append(projs, wtProj) |
| } |
| } |
| |
| return runProjectsParallel(jirix, projs, func(wp Project) error { |
| parentProj := parentProjects[wp.Key()] |
| scm := gitutil.New(jirix, gitutil.RootDirOpt(wp.Path)) |
| currentRev, err := scm.CurrentRevision() |
| if err != nil { |
| return err |
| } |
| |
| targetRev := parentProj.Revision |
| if targetRev == "" || targetRev == "HEAD" { |
| if parentProj.RemoteBranch != "" { |
| targetRev = "refs/remotes/origin/" + parentProj.RemoteBranch |
| } else { |
| targetRev = "refs/remotes/origin/main" |
| } |
| } |
| |
| resolvedTargetRev, err := scm.CurrentRevisionForRef(targetRev) |
| if err != nil { |
| if err := scm.Fetch("origin"); err != nil { |
| return fmt.Errorf("failed to fetch for %s: %v", wp.Name, err) |
| } |
| resolvedTargetRev, err = scm.CurrentRevisionForRef(targetRev) |
| if err != nil { |
| return fmt.Errorf("failed to resolve %s after fetch for %s: %v", targetRev, wp.Name, err) |
| } |
| } |
| |
| if currentRev == resolvedTargetRev { |
| return nil |
| } |
| |
| if err := scm.Checkout(resolvedTargetRev, gitutil.DetachOpt(true)); err != nil { |
| return fmt.Errorf("failed to checkout %s to %s: %v", wp.Name, resolvedTargetRev, err) |
| } |
| return nil |
| }) |
| } |
| |
| func WorktreePrune(jirix *jiri.X) error { |
| paths, err := WorktreeList(jirix) |
| if err != nil { |
| return err |
| } |
| for _, p := range paths { |
| if _, err := os.Stat(p); os.IsNotExist(err) { |
| if err := deregisterWorktree(jirix, p); err != nil { |
| jirix.Logger.Warningf("Failed to deregister missing worktree %s: %v", p, err) |
| } |
| } |
| } |
| |
| parentProjects, err := LocalProjects(jirix, FullScan) |
| if err != nil { |
| return err |
| } |
| manifestProjects, _, _, err := LoadManifestFile(jirix, jirix.JiriManifestFile(), parentProjects, nil) |
| if err != nil { |
| return err |
| } |
| |
| var multiErr error |
| for _, proj := range manifestProjects { |
| if parentProj, ok := parentProjects[proj.Key()]; ok { |
| git := gitutil.New(jirix, gitutil.RootDirOpt(parentProj.Path)) |
| if err := git.WorktreePrune(); err != nil { |
| multiErr = errors.Join(multiErr, fmt.Errorf("failed to prune worktrees in %s: %v", parentProj.Name, err)) |
| } |
| } |
| } |
| if err := PackageGC(jirix, 168*time.Hour); err != nil { |
| jirix.Logger.Warningf("Package garbage collection failed: %v", err) |
| } |
| return multiErr |
| } |
| |
| // WorktreeRemove safely removes a Jiri worktree at the given path. |
| func WorktreeRemove(jirix *jiri.X, wtRoot string, force bool) error { |
| if !filepath.IsAbs(wtRoot) { |
| wtRoot = filepath.Clean(filepath.Join(jirix.Cwd, wtRoot)) |
| } |
| |
| if _, err := os.Stat(wtRoot); os.IsNotExist(err) { |
| list, errList := WorktreeList(jirix) |
| if errList != nil { |
| return errList |
| } |
| if !slices.Contains(list, wtRoot) { |
| return fmt.Errorf("worktree %q not found", wtRoot) |
| } |
| // If the worktree directory was already deleted manually on disk, |
| // run prune on parent repos to clean up orphaned git worktree metadata. |
| return WorktreePrune(jirix) |
| } |
| |
| wtJirix, err := getWorkspaceContext(jirix, wtRoot) |
| if err != nil { |
| return err |
| } |
| |
| wtProjects, err := LocalProjects(wtJirix, FullScan) |
| if err != nil { |
| return err |
| } |
| |
| // Collect package paths (needed for safety checks and cleanup) |
| var pkgPaths []string |
| localManifestProjects, err := getDefaultLocalManifestProjects(wtJirix) |
| if err == nil { |
| _, _, pkgs, err := LoadManifestFile(wtJirix, filepath.Join(wtRoot, jiri.JiriManifestFile), wtProjects, localManifestProjects) |
| if err == nil { |
| for _, pkg := range pkgs { |
| pkgPaths = append(pkgPaths, filepath.Join(wtRoot, pkg.Path)) |
| } |
| } |
| } |
| |
| // Sort the projects between root project and the rest of the projects. |
| var rootProj *Project |
| var otherProjects []Project |
| for _, wtProj := range wtProjects { |
| relPath, err := filepath.Rel(wtJirix.Root, wtProj.Path) |
| if err != nil { |
| return err |
| } |
| if relPath == "." { |
| rootProj = &wtProj |
| } else { |
| otherProjects = append(otherProjects, wtProj) |
| } |
| } |
| |
| // Safety checks |
| if !force { |
| err := func() error { |
| jirix.TimerPush("safety checks") |
| defer jirix.TimerPop() |
| allWtsToCheck := append([]Project{}, otherProjects...) |
| if rootProj != nil { |
| allWtsToCheck = append(allWtsToCheck, *rootProj) |
| } |
| return runProjectsParallel(jirix, allWtsToCheck, func(p Project) error { |
| dirtyFiles, err := getDirtyFiles(jirix, p, wtProjects, pkgPaths) |
| if err != nil { |
| return err |
| } |
| if len(dirtyFiles) > 0 { |
| return fmt.Errorf("worktree %s contains modified or untracked files:\n%s", p.Name, strings.Join(dirtyFiles, "\n")) |
| } |
| return nil |
| }) |
| }() |
| if err != nil { |
| return err |
| } |
| // If we got here, all worktrees are clean (except for ignored submodules or Jiri metadata). |
| // We can now force remove them to bypass Git's submodule safety checks. |
| force = true |
| } |
| |
| parentProjects, err := LocalProjects(jirix, FullScan) |
| if err != nil { |
| return err |
| } |
| |
| if len(otherProjects) > 0 { |
| jirix.TimerPush("remove project worktrees") |
| err := runProjectsParallelWithDepth(jirix, otherProjects, wtJirix.Root, true /*reverse*/, func(p Project) error { |
| return removeProjectWorktree(jirix, p, parentProjects, force) |
| }) |
| jirix.TimerPop() |
| if err != nil { |
| return err |
| } |
| } |
| |
| // Remove root project worktree last. |
| if rootProj != nil { |
| cleanupJiriMetadata(jirix, wtJirix.Root, pkgPaths) |
| jirix.TimerPush("remove root worktree") |
| err := removeProjectWorktree(jirix, *rootProj, parentProjects, force) |
| jirix.TimerPop() |
| if err != nil { |
| return err |
| } |
| } |
| |
| if err := os.RemoveAll(wtJirix.Root); err != nil { |
| return err |
| } |
| if err := deregisterWorktree(jirix, wtRoot); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| func removeProjectWorktree(jirix *jiri.X, wtProj Project, parentProjects Projects, force bool) error { |
| parentProj, ok := parentProjects[wtProj.Key()] |
| if !ok { |
| return fmt.Errorf("parent project for %s not found", wtProj.Name) |
| } |
| |
| parentProjGit := gitutil.New(jirix, gitutil.RootDirOpt(parentProj.Path)) |
| |
| if err := parentProjGit.WorktreeRemove(wtProj.Path, gitutil.ForceOpt(force)); err != nil { |
| if force { |
| jirix.Logger.Warningf("git worktree remove failed for %s (%v); proceeding with directory cleanup", wtProj.Name, err) |
| if err := os.RemoveAll(wtProj.Path); err != nil && !os.IsNotExist(err) { |
| jirix.Logger.Warningf("failed to delete worktree directory %s: %v", wtProj.Name, err) |
| } |
| return nil |
| } |
| return fmt.Errorf("git worktree remove failed for %s: %v", wtProj.Name, err) |
| } |
| |
| return nil |
| } |
| |
| func WorktreeList(jirix *jiri.X) ([]string, error) { |
| regPath := getWorktreesRegistryPath(jirix) |
| list, err := readRegistry(regPath) |
| if err != nil { |
| if os.IsNotExist(err) { |
| // Fallback: if registry doesn't exist, we might have legacy worktrees in default dir. |
| root := jirix.Root |
| if jirix.WorktreeParentRoot != "" { |
| root = jirix.WorktreeParentRoot |
| } |
| wtRootParent := filepath.Join(root, ".jiri_root", "worktrees") |
| dirEntries, errDir := os.ReadDir(wtRootParent) |
| if errDir != nil { |
| if os.IsNotExist(errDir) { |
| return nil, nil |
| } |
| return nil, errDir |
| } |
| var legacyList []string |
| for _, entry := range dirEntries { |
| if entry.IsDir() { |
| legacyList = append(legacyList, filepath.Join(wtRootParent, entry.Name())) |
| } |
| } |
| sort.Strings(legacyList) |
| return legacyList, nil |
| } |
| return nil, err |
| } |
| sort.Strings(list) |
| return list, nil |
| } |
| |
| // WorktreeInfo holds path and sync status of a worktree. |
| type WorktreeInfo struct { |
| Path string |
| Synced bool |
| Missing bool |
| } |
| |
| // WorktreeListWithStatus returns a list of Jiri worktrees with their sync status. |
| func WorktreeListWithStatus(jirix *jiri.X) ([]WorktreeInfo, error) { |
| paths, err := WorktreeList(jirix) |
| if err != nil { |
| return nil, err |
| } |
| var list []WorktreeInfo |
| for _, p := range paths { |
| if _, err := os.Stat(p); os.IsNotExist(err) { |
| list = append(list, WorktreeInfo{ |
| Path: p, |
| Synced: false, |
| Missing: true, |
| }) |
| } else { |
| wtJirix, err := getWorkspaceContext(jirix, p) |
| if err != nil { |
| return nil, err |
| } |
| list = append(list, WorktreeInfo{ |
| Path: p, |
| Synced: wtJirix.WorktreeSynced(), |
| Missing: false, |
| }) |
| } |
| } |
| return list, nil |
| } |
| |
| // getPkgDir returns the top-level package directory inside the cache for a given path. |
| // The package cache directory structure is cacheDir/[package_group_hash]/... |
| // For example, if path is /root/.jiri_root/packages/3b2c019a84/bin/go and cacheDir is |
| // /root/.jiri_root/packages, then it returns /root/.jiri_root/packages/3b2c019a84. |
| func getPkgDir(cacheDir, path string) string { |
| rel, err := filepath.Rel(cacheDir, path) |
| if err != nil { |
| return "" |
| } |
| components := strings.Split(filepath.ToSlash(rel), "/") |
| if len(components) == 0 || components[0] == "." || components[0] == ".." { |
| return "" |
| } |
| return filepath.Join(cacheDir, components[0]) |
| } |
| |
| // walkCachePath recursively walks a cache directory path and resolves any nested |
| // symlinks pointing back into the cache, marking their target package directories |
| // as used. This resolves transitive package-to-package references within the cache. |
| // It uses the used map to prevent visiting the same path twice (circular loops). |
| func walkCachePath(jirix *jiri.X, path string, used map[string]bool, cachePrefix string, cacheDir string) error { |
| if used[path] { |
| return nil |
| } |
| used[path] = true |
| |
| if pkgDir := getPkgDir(cacheDir, path); pkgDir != "" { |
| used[pkgDir] = true |
| } |
| |
| return filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { |
| if err != nil { |
| if os.IsNotExist(err) { |
| jirix.Logger.Debugf("Cache path %s does not exist: %v", p, err) |
| return nil |
| } |
| jirix.Logger.Warningf("Error walking cache path %s: %v", p, err) |
| return nil |
| } |
| if d.Type()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(p) |
| if err != nil { |
| jirix.Logger.Warningf("Failed to read link %s: %v", p, err) |
| return nil |
| } |
| absTarget := target |
| if !filepath.IsAbs(target) { |
| absTarget = filepath.Clean(filepath.Join(filepath.Dir(p), target)) |
| } |
| if strings.HasPrefix(absTarget, cachePrefix) { |
| if fi, err := os.Stat(absTarget); err == nil && fi.IsDir() { |
| if err := walkCachePath(jirix, absTarget, used, cachePrefix, cacheDir); err != nil { |
| return err |
| } |
| } else { |
| if pkgDir := getPkgDir(cacheDir, absTarget); pkgDir != "" { |
| used[pkgDir] = true |
| } |
| } |
| } |
| } |
| return nil |
| }) |
| } |
| |
| // findUsedCachePaths scans the main workspace and all active worktrees to identify |
| // all cache package directories currently pointed to by symlinks. |
| func findUsedCachePaths(jirix *jiri.X, mainRoot string, worktrees []string, cacheDir string) (map[string]bool, error) { |
| used := make(map[string]bool) |
| dirsToWalk := append([]string{mainRoot}, worktrees...) |
| cachePrefix := cacheDir + string(filepath.Separator) |
| |
| for _, dir := range dirsToWalk { |
| var excludePaths []string |
| for _, p := range jirix.ExcludeDirs { |
| if p == "prebuilt" { |
| continue |
| } |
| excludePaths = append(excludePaths, filepath.Join(dir, p)) |
| } |
| |
| err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { |
| if err != nil { |
| jirix.Logger.Warningf("Error walking path %s: %v", path, err) |
| return nil |
| } |
| if d.IsDir() { |
| name := d.Name() |
| if name == ".git" || name == ".jiri_root" { |
| return filepath.SkipDir |
| } |
| for _, ep := range excludePaths { |
| if path == ep { |
| return filepath.SkipDir |
| } |
| } |
| return nil |
| } |
| |
| if d.Type()&os.ModeSymlink != 0 { |
| target, err := os.Readlink(path) |
| if err != nil { |
| jirix.Logger.Warningf("Failed to read link %s: %v", path, err) |
| return nil |
| } |
| |
| absTarget := target |
| if !filepath.IsAbs(target) { |
| absTarget = filepath.Clean(filepath.Join(filepath.Dir(path), target)) |
| } |
| |
| if strings.HasPrefix(absTarget, cachePrefix) { |
| if fi, err := os.Stat(absTarget); err == nil && fi.IsDir() { |
| if err := walkCachePath(jirix, absTarget, used, cachePrefix, cacheDir); err != nil { |
| return err |
| } |
| } else { |
| if pkgDir := getPkgDir(cacheDir, absTarget); pkgDir != "" { |
| used[pkgDir] = true |
| } |
| } |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| } |
| return used, nil |
| } |
| |
| func PackageGC(jirix *jiri.X, threshold time.Duration) error { |
| cacheDir := jirix.PackageCacheDir() |
| if cacheDir == "" { |
| return nil |
| } |
| |
| jirix.Logger.Infof("Running package garbage collection in cache %s with threshold %v", cacheDir, threshold) |
| |
| mainRoot := jirix.Root |
| if jirix.WorktreeParentRoot != "" { |
| mainRoot = jirix.WorktreeParentRoot |
| } |
| |
| worktrees, err := WorktreeList(jirix) |
| if err != nil { |
| return err |
| } |
| |
| usedPaths, err := findUsedCachePaths(jirix, mainRoot, worktrees, cacheDir) |
| if err != nil { |
| return err |
| } |
| |
| hashDirs, err := os.ReadDir(cacheDir) |
| if err != nil { |
| if os.IsNotExist(err) { |
| return nil |
| } |
| return err |
| } |
| |
| now := time.Now() |
| cutoff := now.Add(-threshold) |
| |
| for _, hd := range hashDirs { |
| if !hd.IsDir() { |
| continue |
| } |
| if hd.Name() == "tmp" { |
| // Ignore the tmp directory, which is used by CIPD for in-progress |
| // package downloads and extractions, to avoid corrupting concurrent updates. |
| continue |
| } |
| pkgDir := filepath.Join(cacheDir, hd.Name()) |
| |
| if usedPaths[pkgDir] { |
| continue |
| } |
| |
| info, err := os.Stat(pkgDir) |
| if err != nil { |
| jirix.Logger.Warningf("Failed to stat cached package %s: %v", pkgDir, err) |
| continue |
| } |
| |
| if info.ModTime().Before(cutoff) { |
| jirix.Logger.Infof("Deleting unused package %s (mtime: %v)", pkgDir, info.ModTime()) |
| if err := os.RemoveAll(pkgDir); err != nil { |
| jirix.Logger.Warningf("Failed to delete %s: %v", pkgDir, err) |
| } |
| } |
| } |
| |
| return nil |
| } |
| |
| func symlinkPackagesFromCache(jirix *jiri.X, parentRoot, wtRoot string, pkgs Packages) error { |
| groups, err := groupPackages(jirix, pkgs) |
| if err != nil { |
| return err |
| } |
| |
| for destRelPath, groupPkgs := range groups { |
| hash := computeGroupHash(groupPkgs) |
| cacheDir := filepath.Join(jirix.PackageCacheDir(), hash) |
| wtDestDir := filepath.Join(wtRoot, destRelPath) |
| |
| if _, err := os.Stat(cacheDir); err == nil { |
| if err := os.MkdirAll(filepath.Dir(wtDestDir), 0755); err != nil { |
| return err |
| } |
| if err := os.Symlink(cacheDir, wtDestDir); err != nil { |
| if !os.IsExist(err) { |
| return err |
| } |
| } |
| } |
| } |
| return nil |
| } |
| |
| func getDefaultLocalManifestProjects(jirix *jiri.X) ([]string, error) { |
| manifest, err := ManifestFromFile(jirix, jirix.JiriManifestFile()) |
| if err != nil { |
| return nil, err |
| } |
| var localManifestProjects []string |
| for _, imp := range manifest.Imports { |
| localManifestProjects = append(localManifestProjects, imp.Name) |
| } |
| return localManifestProjects, nil |
| } |
| |
| func getWorktreesRegistryPath(jirix *jiri.X) string { |
| root := jirix.Root |
| if jirix.WorktreeParentRoot != "" { |
| root = jirix.WorktreeParentRoot |
| } |
| return filepath.Join(root, ".jiri_root", "worktrees_registry") |
| } |
| |
| func registerWorktree(jirix *jiri.X, path string) error { |
| path, err := filepath.Abs(path) |
| if err != nil { |
| return err |
| } |
| regPath := getWorktreesRegistryPath(jirix) |
| |
| wts, err := readRegistry(regPath) |
| if err != nil && !os.IsNotExist(err) { |
| return err |
| } |
| |
| for _, wt := range wts { |
| if wt == path { |
| return nil // Already registered |
| } |
| } |
| |
| wts = append(wts, path) |
| return writeRegistry(regPath, wts) |
| } |
| |
| func deregisterWorktree(jirix *jiri.X, path string) error { |
| path, err := filepath.Abs(path) |
| if err != nil { |
| return err |
| } |
| regPath := getWorktreesRegistryPath(jirix) |
| |
| wts, err := readRegistry(regPath) |
| if err != nil { |
| if os.IsNotExist(err) { |
| return nil |
| } |
| return err |
| } |
| |
| var newWts []string |
| for _, wt := range wts { |
| if wt != path { |
| newWts = append(newWts, wt) |
| } |
| } |
| |
| return writeRegistry(regPath, newWts) |
| } |
| |
| func readRegistry(regPath string) ([]string, error) { |
| data, err := os.ReadFile(regPath) |
| if err != nil { |
| return nil, err |
| } |
| var wts []string |
| lines := strings.Split(string(data), "\n") |
| for _, line := range lines { |
| line = strings.TrimSpace(line) |
| if line != "" { |
| wts = append(wts, line) |
| } |
| } |
| return wts, nil |
| } |
| |
| func writeRegistry(regPath string, wts []string) error { |
| if err := os.MkdirAll(filepath.Dir(regPath), 0755); err != nil { |
| return err |
| } |
| data := strings.Join(wts, "\n") |
| if len(wts) > 0 { |
| data += "\n" |
| } |
| return os.WriteFile(regPath, []byte(data), 0644) |
| } |
| |
| func cleanupJiriMetadata(jirix *jiri.X, wtRoot string, pkgPaths []string) { |
| // 1. Remove package symlinks (old style worktrees) or hard-linked directories (new style). |
| for _, pkgPath := range pkgPaths { |
| if fi, err := os.Lstat(pkgPath); err == nil { |
| if fi.Mode()&os.ModeSymlink != 0 { |
| // Old style worktree used symlinks for packages. |
| if err := os.Remove(pkgPath); err != nil { |
| jirix.Logger.Warningf("Failed to remove package symlink %s: %v", pkgPath, err) |
| } |
| } else if fi.IsDir() { |
| // New style worktree uses hard-linked directories. |
| // We identify them by checking for the `.jiri_cache_hash` file. |
| hashFile := filepath.Join(pkgPath, ".jiri_cache_hash") |
| if _, err := os.Stat(hashFile); err == nil { |
| if err := os.RemoveAll(pkgPath); err != nil { |
| jirix.Logger.Warningf("Failed to remove package directory %s: %v", pkgPath, err) |
| } |
| } |
| } |
| } |
| } |
| |
| // 2. Remove .jiri_manifest |
| manifestPath := filepath.Join(wtRoot, jiri.JiriManifestFile) |
| if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) { |
| jirix.Logger.Warningf("Failed to remove %s: %v", manifestPath, err) |
| } |
| |
| // 3. Remove .jiri_root |
| jiriRootPath := filepath.Join(wtRoot, jiri.RootMetaDir) |
| if err := os.RemoveAll(jiriRootPath); err != nil { |
| jirix.Logger.Warningf("Failed to remove %s: %v", jiriRootPath, err) |
| } |
| } |
| |
| func getDirtyFiles(jirix *jiri.X, wtProj Project, allProjects Projects, pkgPaths []string) ([]string, error) { |
| wtGit := gitutil.New(jirix, gitutil.RootDirOpt(wtProj.Path)) |
| items, err := wtGit.Status() |
| if err != nil { |
| return nil, err |
| } |
| if len(items) == 0 { |
| return nil, nil |
| } |
| |
| // Find nested projects |
| var nestedProjRelPaths []string |
| for _, p := range allProjects { |
| if p.Key() == wtProj.Key() { |
| continue |
| } |
| relPath, err := filepath.Rel(wtProj.Path, p.Path) |
| if err == nil && !strings.HasPrefix(relPath, "..") && relPath != "." { |
| nestedProjRelPaths = append(nestedProjRelPaths, filepath.Clean(relPath)) |
| } |
| } |
| |
| var dirtyFiles []string |
| for _, item := range items { |
| // We need to check both Path and OldPath (if rename) |
| pathsToCheck := []string{item.Path} |
| if item.OldPath != "" { |
| pathsToCheck = append(pathsToCheck, item.OldPath) |
| } |
| |
| for _, path := range pathsToCheck { |
| cleanedPath := filepath.Clean(path) |
| |
| if cleanedPath == jiri.JiriManifestFile || cleanedPath == jiri.RootMetaDir || strings.HasPrefix(cleanedPath, jiri.RootMetaDir+string(filepath.Separator)) { |
| continue |
| } |
| |
| // Filter out nested project paths |
| isNestedProj := false |
| for _, npPath := range nestedProjRelPaths { |
| if cleanedPath == npPath || strings.HasPrefix(cleanedPath, npPath+string(filepath.Separator)) { |
| isNestedProj = true |
| break |
| } |
| } |
| if isNestedProj { |
| continue |
| } |
| |
| // Filter out package paths |
| isPkg := false |
| for _, pkgPath := range pkgPaths { |
| relPkgPath, err := filepath.Rel(wtProj.Path, pkgPath) |
| if err == nil && filepath.Clean(relPkgPath) == cleanedPath { |
| isPkg = true |
| break |
| } |
| } |
| if isPkg { |
| continue |
| } |
| |
| // Reconstruct status line for reporting |
| statusStr := fmt.Sprintf("%c%c %s", item.X, item.Y, path) |
| dirtyFiles = append(dirtyFiles, statusStr) |
| } |
| } |
| |
| return dirtyFiles, nil |
| } |