blob: 86288b70b55a8455ad1aba5d925d957ab04fe1f5 [file]
// 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
}