this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

internal/vcs: new package

This provides access to the VCS primitives that are required
when determining what files what go into a module.

For now, git is enough but we plan to support other VCS
systems in the future.

For #3017.

Signed-off-by: Roger Peppe <rogpeppe@gmail.com>
Change-Id: I9789600d06b3424b22dd08351db6938d2929df7d
Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1192906
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>

+406
+75
internal/vcs/copyfs_test.go
··· 1 + // Copyright 2023 The Go Authors. All rights reserved. 2 + // Use of this source code is governed by a BSD-style 3 + // license that can be found in the LICENSE file. 4 + 5 + package vcs 6 + 7 + import ( 8 + "io" 9 + "io/fs" 10 + "os" 11 + "path/filepath" 12 + ) 13 + 14 + // copyFS is copied from Go tip as of 315b6ae682a2a4e7718924a45b8b311a0fe10043. 15 + // It's slightly adapted to avoid localizing names, 16 + // because that relies on Go internal functions and we don't need 17 + // to be that careful for this use case. 18 + // TODO use [os.CopyFS] when we can. 19 + 20 + // copyFS copies the file system fsys into the directory dir, 21 + // creating dir if necessary. 22 + // 23 + // Newly created directories and files have their default modes 24 + // where any bits from the file in fsys that are not part of the 25 + // standard read, write, and execute permissions will be zeroed 26 + // out, and standard read and write permissions are set for owner, 27 + // group, and others while retaining any existing execute bits from 28 + // the file in fsys. 29 + // 30 + // Symbolic links in fsys are not supported, a *PathError with Err set 31 + // to ErrInvalid is returned on symlink. 32 + // 33 + // Copying stops at and returns the first error encountered. 34 + func copyFS(dir string, fsys fs.FS) error { 35 + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { 36 + if err != nil { 37 + return err 38 + } 39 + 40 + // The original version calls safefilepath.Localize here 41 + // but we don't have access to that and the context is 42 + // limited enough that it shouldn't matter. 43 + newPath := filepath.Join(dir, path) 44 + if d.IsDir() { 45 + return os.MkdirAll(newPath, 0777) 46 + } 47 + 48 + // TODO(panjf2000): handle symlinks with the help of fs.ReadLinkFS 49 + // once https://go.dev/issue/49580 is done. 50 + // we also need safefilepath.IsLocal from https://go.dev/cl/564295. 51 + if !d.Type().IsRegular() { 52 + return &os.PathError{Op: "CopyFS", Path: path, Err: os.ErrInvalid} 53 + } 54 + 55 + r, err := fsys.Open(path) 56 + if err != nil { 57 + return err 58 + } 59 + defer r.Close() 60 + info, err := r.Stat() 61 + if err != nil { 62 + return err 63 + } 64 + w, err := os.OpenFile(newPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666|info.Mode()&0777) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + if _, err := io.Copy(w, r); err != nil { 70 + w.Close() 71 + return &os.PathError{Op: "Copy", Path: newPath, Err: err} 72 + } 73 + return w.Close() 74 + }) 75 + }
+113
internal/vcs/git.go
··· 1 + // Copyright 2024 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package vcs 16 + 17 + import ( 18 + "context" 19 + "fmt" 20 + "path/filepath" 21 + "sort" 22 + "strconv" 23 + "strings" 24 + "time" 25 + ) 26 + 27 + type gitVCS struct { 28 + root string 29 + } 30 + 31 + func newGitVCS(dir string) (VCS, error) { 32 + root := findRoot(dir, ".git") 33 + if root == "" { 34 + return nil, &vcsNotFoundError{ 35 + kind: "git", 36 + dir: dir, 37 + } 38 + } 39 + return gitVCS{ 40 + root: root, 41 + }, nil 42 + } 43 + 44 + // Root implements [VCS.Root]. 45 + func (v gitVCS) Root() string { 46 + return v.root 47 + } 48 + 49 + // ListFiles implements [VCS.ListFiles]. 50 + func (v gitVCS) ListFiles(ctx context.Context, dir string) ([]string, error) { 51 + rel, err := filepath.Rel(v.root, dir) 52 + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { 53 + return nil, fmt.Errorf("cannot list files from %q, outside VCS root %q", dir, v.root) 54 + } 55 + // TODO should we use --recurse-submodules? 56 + out, err := runCmd(ctx, dir, "git", "ls-files", "-z") 57 + if err != nil { 58 + return nil, err 59 + } 60 + files := strings.Split(strings.TrimSuffix(out, "\x00"), "\x00") 61 + sort.Strings(files) 62 + return files, nil 63 + } 64 + 65 + // Status implements [VCS.Status]. 66 + func (v gitVCS) Status(ctx context.Context) (Status, error) { 67 + out, err := runCmd(ctx, v.root, "git", "status", "--porcelain") 68 + if err != nil { 69 + return Status{}, err 70 + } 71 + uncommitted := len(out) > 0 72 + 73 + // "git status" works for empty repositories, but "git log" does not. 74 + // Assume there are no commits in the repo when "git log" fails with 75 + // uncommitted files and skip tagging revision / committime. 76 + var rev string 77 + var commitTime time.Time 78 + out, err = runCmd(ctx, v.root, "git", 79 + "-c", "log.showsignature=false", 80 + "log", "-1", "--format=%H:%ct", 81 + ) 82 + if err != nil && !uncommitted { 83 + return Status{}, err 84 + } 85 + if err == nil { 86 + rev, commitTime, err = parseRevTime(out) 87 + if err != nil { 88 + return Status{}, err 89 + } 90 + } 91 + return Status{ 92 + Revision: rev, 93 + CommitTime: commitTime, 94 + Uncommitted: uncommitted, 95 + }, nil 96 + } 97 + 98 + // parseRevTime parses commit details in "revision:seconds" format. 99 + func parseRevTime(out string) (string, time.Time, error) { 100 + buf := strings.TrimSpace(out) 101 + 102 + rev, t, _ := strings.Cut(buf, ":") 103 + if rev == "" { 104 + return "", time.Time{}, fmt.Errorf("unrecognized VCS tool output %q", out) 105 + } 106 + 107 + secs, err := strconv.ParseInt(t, 10, 64) 108 + if err != nil { 109 + return "", time.Time{}, fmt.Errorf("unrecognized VCS tool output: %v", err) 110 + } 111 + 112 + return rev, time.Unix(secs, 0), nil 113 + }
+123
internal/vcs/vcs.go
··· 1 + // Copyright 2024 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + // Package vcs provides access to operations on the version control 16 + // systems supported by the source field in module.cue. 17 + package vcs 18 + 19 + import ( 20 + "context" 21 + "fmt" 22 + "os" 23 + "os/exec" 24 + "path/filepath" 25 + "time" 26 + ) 27 + 28 + // VCS provides the operations on a particular instance of a VCS. 29 + type VCS interface { 30 + // Root returns the root of the directory controlled by 31 + // the VCS (e.g. the directory containing .git). 32 + Root() string 33 + 34 + // ListFiles returns a list of all the files tracked by the VCS 35 + // under the given directory, relative to that directory, as 36 + // filepaths, in lexical order. It does not include directory 37 + // names. 38 + // 39 + // The directory should be within the VCS root. 40 + ListFiles(ctx context.Context, dir string) ([]string, error) 41 + 42 + // Status returns the current state of the repository holding 43 + // the given directory. 44 + Status(ctx context.Context) (Status, error) 45 + } 46 + 47 + // Status is the current state of a local repository. 48 + type Status struct { 49 + Revision string // Optional. 50 + CommitTime time.Time // Optional. 51 + Uncommitted bool // Required. 52 + } 53 + 54 + var vcsTypes = map[string]func(dir string) (VCS, error){ 55 + "git": newGitVCS, 56 + } 57 + 58 + // New returns a new VCS value representing the 59 + // version control system of the given type that 60 + // controls the given directory. 61 + // 62 + // It returns an error if a VCS of the specified type 63 + // cannot be found. 64 + func New(vcsType string, dir string) (VCS, error) { 65 + vf := vcsTypes[vcsType] 66 + if vf == nil { 67 + return nil, fmt.Errorf("unrecognized VCS type %q", vcsType) 68 + } 69 + return vf(dir) 70 + } 71 + 72 + // findRoot inspects dir and its parents to find the VCS repository 73 + // signified the presence of one of the given root names. 74 + // 75 + // If no repository is found, findRoot returns the empty string. 76 + func findRoot(dir string, rootNames ...string) string { 77 + dir = filepath.Clean(dir) 78 + for { 79 + if isVCSRoot(dir, rootNames) { 80 + return dir 81 + } 82 + ndir := filepath.Dir(dir) 83 + if len(ndir) >= len(dir) { 84 + break 85 + } 86 + dir = ndir 87 + } 88 + return "" 89 + } 90 + 91 + // isVCSRoot identifies a VCS root by checking whether the directory contains 92 + // any of the listed root names. 93 + func isVCSRoot(dir string, rootNames []string) bool { 94 + for _, root := range rootNames { 95 + if _, err := os.Stat(filepath.Join(dir, root)); err == nil { 96 + // TODO return false if it's not the expected file type. 97 + // For now, this is only used by git which can use both 98 + // files and directories, so we'll allow either. 99 + return true 100 + } 101 + } 102 + return false 103 + } 104 + 105 + func runCmd(ctx context.Context, dir string, cmdName string, args ...string) (string, error) { 106 + cmd := exec.CommandContext(ctx, cmdName, args...) 107 + cmd.Dir = dir 108 + 109 + out, err := cmd.Output() 110 + if err != nil { 111 + return "", fmt.Errorf("running %q %q: %v", cmdName, args, err) 112 + } 113 + return string(out), nil 114 + } 115 + 116 + type vcsNotFoundError struct { 117 + kind string 118 + dir string 119 + } 120 + 121 + func (e *vcsNotFoundError) Error() string { 122 + return fmt.Sprintf("%s VCS not found in any parent of %q", e.kind, e.dir) 123 + }
+95
internal/vcs/vcs_test.go
··· 1 + // Copyright 2024 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package vcs 16 + 17 + import ( 18 + "context" 19 + "os/exec" 20 + "path/filepath" 21 + "testing" 22 + "time" 23 + 24 + "github.com/go-quicktest/qt" 25 + 26 + "cuelang.org/go/internal/txtarfs" 27 + "golang.org/x/tools/txtar" 28 + ) 29 + 30 + var testFS = txtarfs.FS(txtar.Parse([]byte(` 31 + -- subdir/foo -- 32 + -- subdir/bar/baz -- 33 + -- bar.txt -- 34 + -- baz/something -- 35 + `))) 36 + 37 + func TestGit(t *testing.T) { 38 + skipIfNoExecutable(t, "git") 39 + ctx := context.Background() 40 + dir := t.TempDir() 41 + err := copyFS(dir, testFS) 42 + qt.Assert(t, qt.IsNil(err)) 43 + 44 + _, err = New("git", filepath.Join(dir, "subdir")) 45 + qt.Assert(t, qt.ErrorMatches(err, `git VCS not found in any parent of ".+"`)) 46 + 47 + mustRunCmd(t, dir, "git", "init") 48 + v, err := New("git", filepath.Join(dir, "subdir")) 49 + qt.Assert(t, qt.IsNil(err)) 50 + status, err := v.Status(ctx) 51 + qt.Assert(t, qt.IsNil(err)) 52 + qt.Assert(t, qt.IsTrue(status.Uncommitted)) 53 + 54 + mustRunCmd(t, dir, "git", "add", ".") 55 + status, err = v.Status(ctx) 56 + qt.Assert(t, qt.IsNil(err)) 57 + qt.Assert(t, qt.IsTrue(status.Uncommitted)) 58 + 59 + commitTime := time.Now().Truncate(time.Second) 60 + mustRunCmd(t, dir, "git", 61 + "-c", "user.email=cueckoo@gmail.com", 62 + "-c", "user.name=cueckoo", 63 + "commit", "-m", "something", 64 + ) 65 + status, err = v.Status(ctx) 66 + qt.Assert(t, qt.IsNil(err)) 67 + qt.Assert(t, qt.IsFalse(status.Uncommitted)) 68 + qt.Assert(t, qt.IsTrue(!status.CommitTime.Before(commitTime))) 69 + qt.Assert(t, qt.Matches(status.Revision, `[0-9a-f]+`)) 70 + files, err := v.ListFiles(ctx, filepath.Join(dir, "subdir")) 71 + qt.Assert(t, qt.DeepEquals(files, []string{ 72 + "bar/baz", 73 + "foo", 74 + })) 75 + files, err = v.ListFiles(ctx, dir) 76 + qt.Assert(t, qt.DeepEquals(files, []string{ 77 + "bar.txt", 78 + "baz/something", 79 + "subdir/bar/baz", 80 + "subdir/foo", 81 + })) 82 + } 83 + 84 + func mustRunCmd(t *testing.T, dir string, exe string, args ...string) { 85 + c := exec.Command(exe, args...) 86 + c.Dir = dir 87 + data, err := c.CombinedOutput() 88 + qt.Assert(t, qt.IsNil(err), qt.Commentf("output: %q", data)) 89 + } 90 + 91 + func skipIfNoExecutable(t *testing.T, exeName string) { 92 + if _, err := exec.LookPath(exeName); err != nil { 93 + t.Skipf("cannot find %q executable: %v", exeName, err) 94 + } 95 + }