blob: 2c6315fdf4292b24144c763a877ee31649f7edf2 [file] [log] [blame]
// Copyright 2017 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 (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"go.fuchsia.dev/jiri"
"go.fuchsia.dev/jiri/gitutil"
)
type importCache struct {
localManifest bool
ref string
// keeps track of first import tag, this is used to give helpful error
// message in the case of import conflict
parentImport string
}
type loader struct {
Projects Projects
ProjectOverrides map[string]Project
ImportOverrides map[string]Import
ProjectLocks ProjectLocks
Hooks Hooks
Packages Packages
PackageLocks PackageLocks
TmpDir string
localProjects Projects
importProjects Projects
importCacheMap map[string]importCache
importTree importTree
update bool
cycleStack []cycleInfo
manifests map[string]bool
lockfiles map[string]bool
parentFile string
}
type importTreeNode struct {
parents map[*importTreeNode]bool
children map[*importTreeNode]bool
tag string
computedAttributes attributes
}
type importTree struct {
root *importTreeNode
pool map[string]*importTreeNode
filenameMap map[string]bool
projectKeyMap map[ProjectKey]*importTreeNode
}
func newImportTree() importTree {
return importTree{
root: &importTreeNode{
make(map[*importTreeNode]bool),
make(map[*importTreeNode]bool),
"",
nil,
},
pool: make(map[string]*importTreeNode),
filenameMap: make(map[string]bool),
projectKeyMap: make(map[ProjectKey]*importTreeNode),
}
}
func (t *importTree) getNode(repoPath, file, ref string) *importTreeNode {
key := filepath.Join(repoPath, file) + ":" + ref
if v, ok := t.pool[key]; ok {
return v
}
v := &importTreeNode{
make(map[*importTreeNode]bool),
make(map[*importTreeNode]bool),
key,
nil,
}
if len(t.pool) == 0 {
t.root.addChild(v)
}
t.pool[key] = v
return v
}
func (t *importTree) buildImportAttributes() {
// Due to the logic in how remote imports are handled, the
// manifest loader will create two nodes for a single remote import,
// causing second one as an orphan. A simple solution is just
// connect the orphan nodes to the Root to build a connected tree.
for _, v := range t.pool {
if len(v.parents) == 0 {
t.root.addChild(v)
}
}
// The file name of a manifest is used as a git attributes name only if that
// file name is unique.
dupMap := make(map[string]bool)
for k := range t.filenameMap {
filename := filepath.Base(k)
if _, ok := dupMap[filename]; ok {
dupMap[filename] = true
} else {
dupMap[filename] = false
}
}
dfsAddAttribute(t.root, newAttributes(""))
for _, node := range t.pool {
for attr := range node.computedAttributes {
if v, ok := dupMap[attr]; ok && v {
delete(node.computedAttributes, attr)
}
}
}
}
func dfsAddAttribute(curNode *importTreeNode, parentAttrs attributes) {
if curNode.computedAttributes == nil {
curNode.computedAttributes = newAttributes(curNode.tag)
}
curNode.computedAttributes.Add(parentAttrs)
for k := range curNode.children {
dfsAddAttribute(k, curNode.computedAttributes)
}
}
// (TODO:haowei) generateAttributeGraph generate a graphviz .dot file of
// importTree for debugging purpose. It should be removed once .gitattribute
// generator is considered as stable.
func (t *importTree) generateAttributeGraph() string {
var buf bytes.Buffer
buf.WriteString("\ndigraph G {\n")
nodeCount := 1
nameMap := make(map[*importTreeNode]string)
nameMap[t.root] = "n0"
for _, v := range t.pool {
nameMap[v] = fmt.Sprintf("n%d", nodeCount)
nodeCount++
}
// The file name of a manifest is used as a git attributes name only if that
// file name is unique.
dupMap := make(map[string]bool)
for k := range t.filenameMap {
filename := filepath.Base(k)
if _, ok := dupMap[filename]; ok {
dupMap[filename] = true
} else {
dupMap[filename] = false
}
}
// Output nodes
for k, v := range nameMap {
attrs := newAttributes(k.tag)
for attr := range attrs {
if v, ok := dupMap[attr]; ok && v {
delete(attrs, attr)
}
}
buf.WriteString(fmt.Sprintf("\t%s[label=\"%s,%p\"];\n", v, attrs.String(), k))
}
buf.WriteString("\n")
// Output edges
for k := range nameMap {
for child := range k.children {
nameSource := nameMap[k]
nameTarget, ok := nameMap[child]
if !ok {
fmt.Printf("ERROR: cound not find target node %q,%p in nameMap\n", child.tag, child)
continue
}
buf.WriteString(fmt.Sprintf("\t%s -> %s;\n", nameSource, nameTarget))
}
}
buf.WriteString("\n}")
return buf.String()
}
func (n *importTreeNode) addChild(other *importTreeNode) {
if other == nil {
return
}
n.children[other] = true
other.parents[n] = true
}
func (ld *loader) cleanup() {
if ld.TmpDir != "" {
os.RemoveAll(ld.TmpDir)
ld.TmpDir = ""
}
}
type cycleInfo struct {
file, key string
}
// newManifestLoader returns a new manifest loader. The localProjects are used
// to resolve remote imports; if nil, encountering any remote import will result
// in an error. If update is true, remote manifest import projects that don't
// exist locally are cloned under TmpDir, and inserted into localProjects.
//
// If update is true, remote changes to manifest projects will be fetched, and
// manifest projects that don't exist locally will be created in temporary
// directories, and added to localProjects.
func newManifestLoader(localProjects Projects, update bool, file string) *loader {
return &loader{
Projects: make(Projects),
ProjectOverrides: make(map[string]Project),
ImportOverrides: make(map[string]Import),
ProjectLocks: make(ProjectLocks),
Hooks: make(Hooks),
Packages: make(Packages),
PackageLocks: make(PackageLocks),
localProjects: localProjects,
importProjects: make(Projects),
update: update,
importCacheMap: make(map[string]importCache),
manifests: make(map[string]bool),
lockfiles: make(map[string]bool),
importTree: newImportTree(),
parentFile: file,
}
}
// loadNoCycles checks for cycles in imports. There are two types of cycles:
//
// file - Cycle in the paths of manifest files in the local filesystem.
// key - Cycle in the remote manifests specified by remote imports.
//
// Example of file cycles. File A imports file B, and vice versa.
//
// file=manifest/A file=manifest/B
// <manifest> <manifest>
// <localimport file="B"/> <localimport file="A"/>
// </manifest> </manifest>
//
// Example of key cycles. The key consists of "remote/manifest", e.g.
//
// https://vanadium.googlesource.com/manifest/v2/default
//
// In the example, key x/A imports y/B, and vice versa.
//
// key=x/A key=y/B
// <manifest> <manifest>
// <import remote="y" manifest="B"/> <import remote="x" manifest="A"/>
// </manifest> </manifest>
//
// The above examples are simple, but the general strategy is demonstrated. We
// keep a single stack for both files and keys, and push onto each stack before
// running the recursive read or update function, and pop the stack when the
// function is done. If we see a duplicate on the stack at any point, we know
// there's a cycle. Note that we know the file for both local and remote
// imports, but we only know the key for remote imports; the key for local
// imports is empty.
//
// A more complex case would involve a combination of local and remote imports,
// using the "root" attribute to change paths on the local filesystem. In this
// case the key will eventually expose the cycle.
func (ld *loader) loadNoCycles(jirix *jiri.X, root, repoPath, file, ref, cycleKey, parentImport string, localManifest bool) error {
f := file
if repoPath != "" {
f = filepath.Join(repoPath, file)
}
info := cycleInfo{f, cycleKey}
for _, c := range ld.cycleStack {
switch {
case f == c.file:
return fmt.Errorf("import cycle detected in local manifest files: %q", append(ld.cycleStack, info))
case cycleKey == c.key && cycleKey != "":
return fmt.Errorf("import cycle detected in remote manifest imports: %q", append(ld.cycleStack, info))
}
}
ld.cycleStack = append(ld.cycleStack, info)
if err := ld.load(jirix, root, repoPath, file, ref, parentImport, localManifest); err != nil {
return err
}
ld.cycleStack = ld.cycleStack[:len(ld.cycleStack)-1]
return nil
}
// shortFileName returns the relative path if file is relative to root,
// otherwise returns the file name unchanged.
func shortFileName(root, repoPath, file, ref string) string {
if repoPath != "" {
return fmt.Sprintf("%s %s:%s", shortFileName(root, "", repoPath, ""), ref, file)
}
if p := root + string(filepath.Separator); strings.HasPrefix(file, p) {
return file[len(p):]
}
return file
}
func (ld *loader) Load(jirix *jiri.X, root, repoPath, file, ref, cycleKey, parentImport string, localManifest bool) error {
jirix.TimerPush("load " + shortFileName(jirix.Root, repoPath, file, ref))
defer jirix.TimerPop()
return ld.loadNoCycles(jirix, root, repoPath, file, ref, cycleKey, parentImport, localManifest)
}
func (ld *loader) cloneManifestRepo(jirix *jiri.X, remote *Import, cacheDirPath string, localManifest bool) error {
if !ld.update || localManifest {
jirix.Logger.Warningf("import %q not found locally, getting from server. Please check your manifest file (default: .jiri_manifest).\nMake sure that the 'name' attributes on the 'import' and 'project' tags match and that there is a corresponding 'project' tag for every 'import' tag.\n\n", remote.Name)
}
jirix.Logger.Debugf("clone manifest project %q", remote.Name)
// The remote manifest project doesn't exist locally. Clone it into a
// temp directory, and add it to ld.localProjects.
if ld.TmpDir == "" {
var err error
if ld.TmpDir, err = os.MkdirTemp("", "jiri-load"); err != nil {
return fmt.Errorf("TempDir() failed: %v", err)
}
}
path := filepath.Join(ld.TmpDir, remote.projectKeyFileName())
p, err := remote.toProject(path)
if err != nil {
return err
}
if err := os.MkdirAll(path, 0755); err != nil {
return fmtError(err)
}
remoteUrl := rewriteRemote(jirix, p.Remote)
task := jirix.Logger.AddTaskMsg("Creating manifest: %s", remote.Name)
defer task.Done()
if cacheDirPath != "" {
logStr := fmt.Sprintf("update/create cache for project %q", remote.Name)
jirix.Logger.Debugf(logStr)
task := jirix.Logger.AddTaskMsg(logStr)
defer task.Done()
if err := updateOrCreateCache(jirix, cacheDirPath, remoteUrl, remote.RemoteBranch, remote.Revision, 0, (p.GitSubmodules && jirix.EnableSubmodules)); err != nil {
return err
}
}
opts := []gitutil.CloneOpt{gitutil.ReferenceOpt(cacheDirPath), gitutil.NoCheckoutOpt(true)}
if jirix.UsePartialClone(p.Remote) {
opts = append(opts, gitutil.OmitBlobsOpt(true))
}
if jirix.Dissociate {
opts = append(opts, gitutil.DissociateOpt(true))
}
if err := clone(jirix, remoteUrl, path, opts...); err != nil {
return err
}
scm := gitutil.New(jirix, gitutil.RootDirOpt(p.Path))
if jirix.UsePartialClone(p.Remote) && cacheDirPath != "" {
// Set Cache Remote
if err := scm.Config("extensions.partialClone", "origin"); err != nil {
return err
}
if err := scm.AddOrReplacePartialRemote("cache", cacheDirPath); err != nil {
return err
}
}
p.Revision = remote.Revision
p.RemoteBranch = remote.RemoteBranch
if err := checkoutHeadRevision(jirix, p, false, false); err != nil {
return fmt.Errorf("Not able to checkout head for %s(%s): %v", p.Name, p.Path, err)
}
ld.localProjects[remote.ProjectKey()] = p
return nil
}
// loadLockFile will recursively load lockfiles from dir to its parent directories until it
// reaches $JIRI_ROOT. It will only report errors on lockfiles such as unknown format or confliting data.
// All I/O related errors will be ignored.
func (ld *loader) loadLockFile(jirix *jiri.X, repoPath, dir, lockFileName, ref string) error {
lockfile := filepath.Join(dir, lockFileName)
lockKey := lockfile
if repoPath != "" {
lockKey = filepath.Join(repoPath, lockfile) + ":" + ref
}
if ld.lockfiles[lockKey] {
return nil
}
if !(dir == "" || dir == "." || dir == jirix.Root || dir == string(filepath.Separator)) {
if err := ld.loadLockFile(jirix, repoPath, filepath.Dir(dir), lockFileName, ref); err != nil {
return err
}
}
var data []byte
if repoPath != "" {
s, err := gitutil.New(jirix, gitutil.RootDirOpt(repoPath)).Show(ref, lockfile)
if err != nil {
// It's fine if jiri.lock cannot be find, skip this jiri.lock
jirix.Logger.Debugf("Could not find %q in repository %q for ref %q", lockfile, repoPath, ref)
return nil
}
data = []byte(s)
} else {
if _, err := os.Stat(lockfile); err != nil {
if os.IsNotExist(err) {
jirix.Logger.Debugf("could not find %q file at %q", lockFileName, lockfile)
} else {
jirix.Logger.Debugf("could not access %q file at %q due to error %v", lockFileName, lockfile, err)
}
return nil
}
temp, err := os.ReadFile(lockfile)
if err != nil {
// Supress I/O errors as it is OK if a lockfile cannot be accessed.
return nil
}
data = temp
}
if err := ld.parseLockData(jirix, data); err != nil {
return err
}
if repoPath == "" {
jirix.Logger.Debugf("loaded lockfile at %s", lockfile)
} else {
jirix.Logger.Debugf("loaded lockfile at %q in repository %q for ref %q", lockfile, repoPath, ref)
}
ld.lockfiles[lockKey] = true
return nil
}
func (ld *loader) parseLockData(jirix *jiri.X, data []byte) error {
projectLocks, pkgLocks, err := UnmarshalLockEntries(data)
if err != nil {
return err
}
for k, v := range projectLocks {
if projLock, ok := ld.ProjectLocks[k]; ok {
if projLock != v && !jirix.UsingImportOverride {
return fmt.Errorf("conflicting project lock entries %+v with %+v", projLock, v)
}
} else {
ld.ProjectLocks[k] = v
}
}
for k, v := range pkgLocks {
if pkgLock, ok := ld.PackageLocks[k]; ok {
// Only package locks may conflict during a normal 'jiri resolve'.
// Treating conflicts as errors in all other scenarios.
if !pkgLock.LockEqual(v) && !jirix.IgnoreLockConflicts && !jirix.UsingImportOverride {
return fmt.Errorf("conflicting package lock entries %+v with %+v", pkgLock, v)
}
} else {
ld.PackageLocks[k] = v
}
}
return nil
}
func (ld *loader) load(jirix *jiri.X, root, repoPath, file, ref, parentImport string, localManifest bool) error {
f := file
if repoPath != "" {
f = filepath.Join(repoPath, file)
}
if ld.manifests[f] {
return nil
}
ld.manifests[f] = true
loadManifestAndLocks := func(jirix *jiri.X, file string) (*Manifest, error) {
if repoPath == "" {
m, err := ManifestFromFile(jirix, file)
if err != nil {
return nil, fmt.Errorf("Error reading from manifest file %s %s:%s:error(%s)", repoPath, ref, file, err)
}
if jirix.LockfileEnabled {
if err := ld.loadLockFile(jirix, repoPath, filepath.Dir(file), jirix.LockfileName, ref); err != nil {
return nil, err
}
}
return m, err
}
// repoPath != ""
s, err := gitutil.New(jirix, gitutil.RootDirOpt(repoPath)).Show(ref, file)
if err != nil {
return nil, fmt.Errorf("Unable to get manifest file for %s %s:%s:error(%s)", repoPath, ref, file, err)
}
m, err := ManifestFromBytes([]byte(s))
if err != nil {
return nil, fmt.Errorf("Error reading from manifest file %s %s:%s:error(%s)", repoPath, ref, file, err)
}
if jirix.LockfileEnabled {
if err := ld.loadLockFile(jirix, repoPath, filepath.Dir(file), jirix.LockfileName, ref); err != nil {
return nil, err
}
}
return m, nil
}
m, err := loadManifestAndLocks(jirix, file)
if err != nil {
return err
}
if jirix.UsingSnapshot && !jirix.OverrideOptional {
// using attributes defined in snapshot file instead of
// using predefined ones in jiri init.
jirix.FetchingAttrs = m.Attributes
}
// Add override information
if parentImport == "" {
for _, p := range m.ProjectOverrides {
// Reuse the MakeProjectKey function in case it is changed
// in the future.
key := p.Key().String()
ld.ProjectOverrides[key] = p
}
for _, p := range m.ImportOverrides {
// Reuse the MakeProjectKey function in case it is changed
// in the future.
key := p.ProjectKey().String()
if !jirix.UsingImportOverride {
jirix.UsingImportOverride = true
}
ld.ImportOverrides[key] = p
}
} else if len(m.ProjectOverrides)+len(m.ImportOverrides) > 0 {
return fmt.Errorf("manifest %q contains overrides but was imported by %q. Overrides are allowed only in the root manifest", shortFileName(jirix.Root, repoPath, file, ref), parentImport)
}
// Use manifest's directory name and file name as default
// git attributes. It will be later expanded using the
// import relationships.
defaultGitAttrs := func() string {
manifestFile := file
if repoPath != "" {
manifestFile = filepath.Join(repoPath, manifestFile)
}
containingDir := filepath.Base(filepath.Dir(manifestFile))
filename := file
ld.importTree.filenameMap[filename] = false
return containingDir + "," + filepath.Base(file)
}
self := ld.importTree.getNode(repoPath, file, ref)
self.tag = defaultGitAttrs()
// Process remote imports.
for _, remote := range m.Imports {
// Apply override if it exists.
remote, err := overrideImport(remote, ld.ProjectOverrides, ld.ImportOverrides)
if err != nil {
return err
}
nextRoot := filepath.Join(root, remote.Root)
remote.Name = filepath.Join(nextRoot, remote.Name)
key := remote.ProjectKey()
p, ok := ld.localProjects[key]
cacheDirPath, err := cacheDirPathFromRemote(jirix, remote.Remote)
if err != nil {
return err
}
if !ok {
if err := ld.cloneManifestRepo(jirix, &remote, cacheDirPath, localManifest); err != nil {
return err
}
p = ld.localProjects[key]
}
// Reset the project to its specified branch and load the next file. Note
// that we call load() recursively, so multiple files may be loaded by
// loadImport.
p.Revision = remote.Revision
p.RemoteBranch = remote.RemoteBranch
ld.importProjects[key] = p
pi := parentImport
if pi == "" {
pi = fmt.Sprintf("import[manifest=%q, remote=%q]", remote.Manifest, remote.Remote)
}
self.addChild(ld.importTree.getNode(repoPath, remote.Manifest, ""))
if err := ld.loadImport(jirix, nextRoot, remote.Manifest, remote.cycleKey(), cacheDirPath, pi, p, localManifest); err != nil {
return err
}
}
// Process local imports.
for _, local := range m.LocalImports {
nextFile := filepath.Join(filepath.Dir(file), local.File)
self.addChild(ld.importTree.getNode(repoPath, nextFile, ref))
if err := ld.Load(jirix, root, repoPath, nextFile, ref, "", parentImport, localManifest); err != nil {
return err
}
}
hookMap := make(map[string][]*Hook)
for idx := range m.Hooks {
hook := &m.Hooks[idx]
if err := hook.validate(); err != nil {
return err
}
hookMap[hook.ProjectName] = append(hookMap[hook.ProjectName], hook)
}
// Collect projects.
for _, project := range m.Projects {
// Apply override if it exists.
project, err := overrideProject(project, ld.ProjectOverrides, ld.ImportOverrides)
if err != nil {
return err
}
// normalize project attributes
project.ComputedAttributes = newAttributes(project.Attributes)
project.Attributes = project.ComputedAttributes.String()
// Make paths absolute by prepending <root>.
project.absolutizePaths(filepath.Join(jirix.Root, root))
if hooks, ok := hookMap[project.Name]; ok {
for _, hook := range hooks {
hook.ActionPath = project.Path
}
}
// Prepend the root to the project name. This will be a noop if the import is not rooted.
project.Name = filepath.Join(root, project.Name)
key := project.Key()
if r, ok := ld.importProjects[key]; ok {
// update revision for this project
if r.Revision != "" && r.Revision != "HEAD" {
if project.Revision == "" || project.Revision == "HEAD" {
project.Revision = r.Revision
} else if r.Revision != project.Revision {
return fmt.Errorf("project %q found in %q defines different revision than its corresponding import tag.", key, shortFileName(jirix.Root, repoPath, file, ref))
}
}
}
if dup, ok := ld.Projects[key]; ok && !reflect.DeepEqual(dup, project) {
// TODO(toddw): Tell the user the other conflicting file.
return fmt.Errorf("duplicate project %q found in %q", key, shortFileName(jirix.Root, repoPath, file, ref))
}
// Record manifest location.
project.ManifestPath = f
// Associate project with importTreeNode for git attributes propagation.
ld.importTree.projectKeyMap[key] = self
ld.Projects[key] = project
}
for _, hook := range m.Hooks {
if hook.ActionPath == "" {
return fmt.Errorf("invalid hook %q for project %q. Please make sure you are importing project %q and this hook is in the manifest which directly/indirectly imports that project.", hook.Name, hook.ProjectName, hook.ProjectName)
}
key := hook.Key()
ld.Hooks[key] = hook
}
for _, pkg := range m.Packages {
// normalize package attributes.
pkg.ComputedAttributes = newAttributes(pkg.Attributes)
pkg.Attributes = pkg.ComputedAttributes.String()
// Record manifest location.
pkg.ManifestPath = f
key := pkg.Key()
if val, ok := ld.Packages[key]; ok {
// Package with same remote url and local path already exists in manifest.
// Abort loading.
return fmt.Errorf("conflicting packages: %v conflicts %v when loading manifest %s", val, pkg, file)
}
ld.Packages[key] = pkg
}
return nil
}
func (ld *loader) loadImport(jirix *jiri.X, root, file, cycleKey, cacheDirPath, parentImport string, project Project, localManifest bool) (e error) {
lm := localManifest
ref := ""
if v, ok := ld.importCacheMap[strings.Trim(project.Remote, "/")]; ok {
// local manifest in cache takes precedence as this might be manifest mentioned in .jiri_manifest
lm = v.localManifest
ref = v.ref
// check conflicting imports
if !lm && ref != "JIRI_HEAD" {
if tref, err := GetHeadRevision(project); err != nil {
return err
} else if tref != ref {
return fmt.Errorf("Conflicting ref for import %s - %q and %q. There are conflicting imports in file:\n%s:\n'%s' and '%s'",
jirix.Color.Red(project.Remote), ref, tref, jirix.Color.Yellow(ld.parentFile),
jirix.Color.Yellow(v.parentImport), jirix.Color.Yellow(parentImport))
}
}
} else {
// We don't need to fetch or find ref for local manifest changes
if !lm {
// We only fetch on updates.
if ld.update {
// Fetch only if project not pinned or revision not available in
// local git as we anyways update all the projects later.
fetch := true
if project.Revision != "" && project.Revision != "HEAD" {
if _, err := gitutil.New(jirix, gitutil.RootDirOpt(project.Path)).Show(project.Revision, ""); err == nil {
fetch = false
}
}
if fetch {
if cacheDirPath != "" {
remoteUrl := rewriteRemote(jirix, project.Remote)
if err := updateOrCreateCache(jirix, cacheDirPath, remoteUrl, project.RemoteBranch, project.Revision, 0, (project.GitSubmodules && jirix.EnableSubmodules)); err != nil {
return err
}
}
if err := fetchAll(jirix, project); err != nil {
return fmt.Errorf("Fetch failed for project(%s), %s", project.Path, err)
}
}
} else {
// If not updating then try to get file from JIRI_HEAD
if _, err := gitutil.New(jirix, gitutil.RootDirOpt(project.Path)).Show("JIRI_HEAD", ""); err == nil {
// JIRI_HEAD available, set ref
ref = "JIRI_HEAD"
}
}
if ref == "" {
var err error
if ref, err = GetHeadRevision(project); err != nil {
return err
}
}
}
ld.importCacheMap[strings.Trim(project.Remote, "/")] = importCache{
localManifest: lm,
ref: ref,
parentImport: parentImport,
}
}
if lm {
// load from local checked out file
return ld.Load(jirix, root, "", filepath.Join(project.Path, file), "", cycleKey, parentImport, false)
}
return ld.Load(jirix, root, project.Path, file, ref, cycleKey, parentImport, false)
}
func (ld *loader) GenerateGitAttributesForProjects(jirix *jiri.X) {
ld.importTree.buildImportAttributes()
for k, v := range ld.Projects {
if treeNode, ok := ld.importTree.projectKeyMap[k]; ok {
v.GitAttributes = treeNode.computedAttributes.String()
ld.Projects[k] = v
}
}
jirix.Logger.Debugf("Generated dot file: %s", ld.importTree.generateAttributeGraph())
}