diff --git a/internal/docs/generated/pkgdocs/docs.go b/internal/docs/generated/pkgdocs/docs.go index 62c6132e50..5ee997b44d 100644 --- a/internal/docs/generated/pkgdocs/docs.go +++ b/internal/docs/generated/pkgdocs/docs.go @@ -206,6 +206,13 @@ var InitExamples = ` var TreeShort = `Display resources, files and packages in a tree structure.` var TreeLong = ` kpt pkg tree [DIR] + +Args: + + DIR: + Path to a local package directory. Defaults to the current directory. + Displays KRM resources with their Kind and Name, and non-KRM text files + as plain filenames. Dotfiles and symlinks are excluded. ` var TreeExamples = ` # Show resources in the current directory. diff --git a/thirdparty/cmdconfig/commands/cmdtree/cmdtree.go b/thirdparty/cmdconfig/commands/cmdtree/cmdtree.go index d81d910258..690dd340de 100644 --- a/thirdparty/cmdconfig/commands/cmdtree/cmdtree.go +++ b/thirdparty/cmdconfig/commands/cmdtree/cmdtree.go @@ -1,11 +1,26 @@ -// Copyright 2019 The Kubernetes Authors. -// SPDX-License-Identifier: Apache-2.0 +// Copyright 2019,2026 The kpt Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. package cmdtree import ( "context" + "fmt" + "io/fs" + "os" "path/filepath" + "strings" "github.com/kptdev/kpt/internal/docs/generated/pkgdocs" "github.com/kptdev/kpt/internal/util/argutil" @@ -45,6 +60,9 @@ type TreeRunner struct { } func (r *TreeRunner) runE(c *cobra.Command, args []string) error { + if err := r.Ctx.Err(); err != nil { + return err + } var input kio.Reader var root = "." if len(args) == 0 { @@ -69,12 +87,58 @@ func (r *TreeRunner) runE(c *cobra.Command, args []string) error { Inputs: []kio.Reader{input}, Filters: fltrs, Outputs: []kio.Writer{TreeWriter{ - Root: root, - Writer: printer.FromContextOrDie(r.Ctx).OutStream(), + Root: root, + Writer: printer.FromContextOrDie(r.Ctx).OutStream(), + NonKRMFiles: discoverNonKRMFiles(r.Ctx, resolvedPath), }}, }.Execute()) } +// discoverNonKRMFiles walks the package tree and returns filenames +// indexed by their containing directory path relative to root. +// Symlinks are skipped. Files that are successfully rendered as KRM resources +// will be deduplicated by the TreeWriter. +func discoverNonKRMFiles(ctx context.Context, root string) map[string][]string { + result := map[string][]string{} + pr := printer.FromContextOrDie(ctx) + + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + fmt.Fprintf(pr.ErrStream(), "[WARN] %s: %v\n", path, err) + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + name := d.Name() + if d.IsDir() { + if path != root && strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(name, ".") { + return nil + } + if name == kptfilev1.KptFileName { + return nil + } + rel, err := filepath.Rel(root, filepath.Dir(path)) + if err != nil { + fmt.Fprintf(pr.ErrStream(), "[WARN] %s: %v\n", path, err) + return nil + } + result[rel] = append(result[rel], name) + return nil + }); err != nil && ctx.Err() == nil { + fmt.Fprintf(pr.ErrStream(), "[WARN] failed to walk %s: %v\n", root, err) + } + return result +} + func (r *TreeRunner) getMatchFilesGlob() []string { return append([]string{kptfilev1.KptFileName}, kio.DefaultMatch...) } diff --git a/thirdparty/cmdconfig/commands/cmdtree/cmdtree_test.go b/thirdparty/cmdconfig/commands/cmdtree/cmdtree_test.go index d9456b41fd..45e2f8545a 100644 --- a/thirdparty/cmdconfig/commands/cmdtree/cmdtree_test.go +++ b/thirdparty/cmdconfig/commands/cmdtree/cmdtree_test.go @@ -1,5 +1,16 @@ -// Copyright 2019 The Kubernetes Authors. -// SPDX-License-Identifier: Apache-2.0 +// Copyright 2019,2026 The kpt Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. package cmdtree @@ -8,11 +19,14 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/kptdev/kpt/internal/testutil" "github.com/kptdev/kpt/pkg/printer/fake" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTreeCommandDefaultCurDir_files(t *testing.T) { @@ -207,7 +221,8 @@ resources: } if !assert.Equal(t, fmt.Sprintf(`%s -└── [f2.yaml] Deployment bar +├── [f2.yaml] Deployment bar +└── Kustomization `, filepath.Base(d)), b.String()) { return } @@ -480,3 +495,141 @@ spec: } assert.Contains(t, stderr.String(), "please note that the symlinks within the package are ignored") } + +// TestTreeCommand_NonKRMInSubpackage verifies non-KRM files in a subpackage +// appear under the subpackage branch, not the parent. +func TestTreeCommand_NonKRMInSubpackage(t *testing.T) { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "sub", "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: sub\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "sub", "NOTES.txt"), []byte("hello\n"), 0600)) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + require.Contains(t, out, `Package "sub"`) + require.Contains(t, out, "NOTES.txt") + subIdx := strings.Index(out, `Package "sub"`) + notesIdx := strings.Index(out, "NOTES.txt") + assert.Greater(t, notesIdx, subIdx, "NOTES.txt should be under the subpackage branch") +} + +// TestTreeCommand_DotfilesExcluded verifies dotfiles and dot-dirs are excluded. +func TestTreeCommand_DotfilesExcluded(t *testing.T) { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, ".hidden"), []byte("secret\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(d, ".git"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(d, ".git", "config"), []byte("[core]\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "visible.txt"), []byte("hi\n"), 0600)) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + assert.Contains(t, out, "visible.txt") + assert.NotContains(t, out, ".hidden") + assert.NotContains(t, out, ".git") + assert.NotContains(t, out, "config") +} + +// TestTreeCommand_SymlinkFileSkipped verifies symlinked files inside a package are skipped. +func TestTreeCommand_SymlinkFileSkipped(t *testing.T) { + if runtime.GOOS == "windows" { + t.SkipNow() + } + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "real.txt"), []byte("content\n"), 0600)) + require.NoError(t, os.Symlink(filepath.Join(d, "real.txt"), filepath.Join(d, "link.txt"))) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + assert.Contains(t, out, "real.txt") + assert.NotContains(t, out, "link.txt") +} + +// TestTreeCommand_MultipleNonKRMSorted verifies multiple non-KRM files are sorted. +func TestTreeCommand_MultipleNonKRMSorted(t *testing.T) { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "zebra.md"), []byte("z\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "alpha.txt"), []byte("a\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "middle.log"), []byte("m\n"), 0600)) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + alphaIdx := strings.Index(out, "alpha.txt") + middleIdx := strings.Index(out, "middle.log") + zebraIdx := strings.Index(out, "zebra.md") + require.NotEqual(t, -1, alphaIdx, "alpha.txt should be present in output") + require.NotEqual(t, -1, middleIdx, "middle.log should be present in output") + require.NotEqual(t, -1, zebraIdx, "zebra.md should be present in output") + assert.Less(t, alphaIdx, middleIdx, "alpha.txt should come before middle.log") + assert.Less(t, middleIdx, zebraIdx, "middle.log should come before zebra.md") +} + +// TestTreeCommand_NonKRMInNonPackageSubdir verifies that non-KRM files inside +// a non-package subdirectory (no Kptfile) are rendered under the parent package +// branch (not as a spurious directory branch), and KRM files in that subdir are +// deduplicated properly. +func TestTreeCommand_NonKRMInNonPackageSubdir(t *testing.T) { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "deployment.yaml"), []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: root-deploy\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(d, "docs"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "docs", "README.md"), []byte("# Hello\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "docs", "svc.yaml"), []byte("apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n"), 0600)) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + // KRM file in subdir should appear as a resource under the "docs" branch + assert.Contains(t, out, "[svc.yaml] Service my-svc") + // Non-KRM file should appear under the same "docs" branch + assert.Contains(t, out, "README.md") + // "docs" should NOT appear as a Package branch (no Kptfile) + assert.NotContains(t, out, `Package "docs"`) + // svc.yaml should appear only once (as KRM, not duplicated as non-KRM) + assert.Equal(t, 1, strings.Count(out, "svc.yaml"), "svc.yaml should appear exactly once") +} + +// TestTreeCommand_DedupKRMFile verifies a YAML file rendered as KRM is not +// duplicated in the non-KRM list. +func TestTreeCommand_DedupKRMFile(t *testing.T) { + d := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(d, "Kptfile"), []byte("apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(d, "cm.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cfg\n"), 0600)) + + b := &bytes.Buffer{} + r := GetTreeRunner(fake.CtxWithPrinter(b, nil), "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(b) + require.NoError(t, r.Command.Execute()) + + out := b.String() + assert.Contains(t, out, "[cm.yaml] ConfigMap cfg") + assert.Equal(t, 1, strings.Count(out, "cm.yaml"), "cm.yaml should appear exactly once (as KRM, not duplicated)") +} diff --git a/thirdparty/cmdconfig/commands/cmdtree/tree.go b/thirdparty/cmdconfig/commands/cmdtree/tree.go index 09cf5632ff..d80528960a 100644 --- a/thirdparty/cmdconfig/commands/cmdtree/tree.go +++ b/thirdparty/cmdconfig/commands/cmdtree/tree.go @@ -1,5 +1,16 @@ -// Copyright 2019 The Kubernetes Authors. -// SPDX-License-Identifier: Apache-2.0 +// Copyright 2019,2026 The kpt Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. package cmdtree @@ -33,10 +44,11 @@ var GraphStructures = []string{string(TreeStructurePackage)} // TODO(pwittrock): test this package better. it is lower-risk since it is only // used for printing rather than updating or editing. type TreeWriter struct { - Writer io.Writer - Root string - Fields []TreeWriterField - Structure TreeStructure + Writer io.Writer + Root string + Fields []TreeWriterField + Structure TreeStructure + NonKRMFiles map[string][]string // root-relative dir → file basenames in that dir, or relative paths after roll-up from subdirs } // TreeWriterField configures a Resource field to be included in the tree @@ -52,10 +64,45 @@ func (p TreeWriter) packageStructure(nodes []*yaml.RNode) error { // create the new tree tree := treeprint.New() + // p.sort sorts nodes within each package (side-effect) and returns sorted keys. + allKeys := p.sort(indexByPackage) + + // Merge NonKRMFiles keys that already exist in the KRM index (these dirs + // already have branches). For dirs not in the index, roll files up to the + // nearest ancestor dir that IS in the index, prefixing with the relative path. + nonKRM := map[string][]string{} + if len(p.NonKRMFiles) > 0 { + krmKeys := map[string]bool{} + for _, k := range allKeys { + krmKeys[k] = true + } + for dir, files := range p.NonKRMFiles { + if krmKeys[dir] { + nonKRM[dir] = append(nonKRM[dir], files...) + continue + } + // Find nearest ancestor that is a known key. + ancestor := dir + for !krmKeys[ancestor] && ancestor != "." { + ancestor = filepath.Dir(ancestor) + } + if !krmKeys[ancestor] { + ancestor = "." + } + // Prefix filenames with the relative path from ancestor to dir. + rel, err := filepath.Rel(ancestor, dir) + if err != nil { + continue + } + for _, f := range files { + nonKRM[ancestor] = append(nonKRM[ancestor], filepath.Join(rel, f)) + } + } + } + // add each package to the tree treeIndex := map[string]treeprint.Tree{} - keys := p.sort(indexByPackage) - for _, pkg := range keys { + for _, pkg := range allKeys { // create a branch for this package -- search for the parent package and create // the branch under it -- requires that the keys are sorted branch := tree @@ -84,6 +131,35 @@ func (p TreeWriter) packageStructure(nodes []*yaml.RNode) error { return err } } + + // print non-KRM files (skip files already rendered as KRM resources) + if len(nonKRM[pkg]) > 0 { + rendered := map[string]bool{} + for _, node := range indexByPackage[pkg] { + _ = kioutil.CopyLegacyAnnotations(node) + meta, _ := node.GetMeta() + if pathAnno := meta.Annotations[kioutil.PathAnnotation]; pathAnno != "" { + // pathAnno is relative to root; strip pkg prefix to get path relative to package + rel := pathAnno + if pkg != "." { + if r, err := filepath.Rel(pkg, pathAnno); err == nil { + rel = r + } + } + rendered[rel] = true + } + } + names := make([]string, 0, len(nonKRM[pkg])) + for _, name := range nonKRM[pkg] { + if !rendered[name] { + names = append(names, name) + } + } + sort.Strings(names) + for _, name := range names { + branch.AddNode(name) + } + } } if p.Root == "." {