From d774ced6a97d3e354d92e874861fb24d7527e3cb Mon Sep 17 00:00:00 2001 From: Josh Bleecher Snyder Date: Tue, 13 Jan 2026 16:45:30 -0800 Subject: [PATCH] cmd/go/internal/vcs: support git worktrees Fixes golang/go#58218 Change-Id: Ia155b26514557cf822caf37e727e5a410b0a36a6 Reviewed-on: https://go-review.googlesource.com/c/go/+/736260 Reviewed-by: Michael Pratt Reviewed-by: Junyang Shao LUCI-TryBot-Result: Go LUCI Reviewed-by: Ian Lance Taylor --- src/cmd/go/internal/vcs/vcs.go | 101 +++++++++++++----- src/cmd/go/internal/vcs/vcs_test.go | 88 +++++++++------ .../script/version_buildvcs_git_worktree.txt | 46 ++++++++ 3 files changed, 175 insertions(+), 60 deletions(-) create mode 100644 src/cmd/go/testdata/script/version_buildvcs_git_worktree.txt diff --git a/src/cmd/go/internal/vcs/vcs.go b/src/cmd/go/internal/vcs/vcs.go index 98ed77d80d6..5613e79e7c1 100644 --- a/src/cmd/go/internal/vcs/vcs.go +++ b/src/cmd/go/internal/vcs/vcs.go @@ -35,10 +35,10 @@ import ( // A Cmd describes how to use a version control system // like Mercurial, Git, or Subversion. type Cmd struct { - Name string - Cmd string // name of binary to invoke command - Env []string // any environment values to set/override - RootNames []rootName // filename and mode indicating the root of a checkout directory + Name string + Cmd string // name of binary to invoke command + Env []string // any environment values to set/override + Roots []isVCSRoot // filters to identify repository root directories Scheme []string PingCmd string @@ -149,8 +149,8 @@ var vcsHg = &Cmd{ // HGPLAIN=+strictflags turns off additional output that a user may have // enabled via config options or certain extensions. Env: []string{"HGPLAIN=+strictflags"}, - RootNames: []rootName{ - {filename: ".hg", isDir: true}, + Roots: []isVCSRoot{ + vcsDirRoot(".hg"), }, Scheme: []string{"https", "http", "ssh"}, @@ -214,8 +214,8 @@ func parseRevTime(out []byte) (string, time.Time, error) { var vcsGit = &Cmd{ Name: "Git", Cmd: "git", - RootNames: []rootName{ - {filename: ".git", isDir: true}, + Roots: []isVCSRoot{ + vcsGitRoot{}, }, Scheme: []string{"git", "https", "http", "git+ssh", "ssh"}, @@ -262,8 +262,8 @@ func gitStatus(vcsGit *Cmd, rootDir string) (Status, error) { var vcsBzr = &Cmd{ Name: "Bazaar", Cmd: "bzr", - RootNames: []rootName{ - {filename: ".bzr", isDir: true}, + Roots: []isVCSRoot{ + vcsDirRoot(".bzr"), }, Scheme: []string{"https", "http", "bzr", "bzr+ssh"}, @@ -332,8 +332,8 @@ func bzrStatus(vcsBzr *Cmd, rootDir string) (Status, error) { var vcsSvn = &Cmd{ Name: "Subversion", Cmd: "svn", - RootNames: []rootName{ - {filename: ".svn", isDir: true}, + Roots: []isVCSRoot{ + vcsDirRoot(".svn"), }, // There is no tag command in subversion. @@ -381,9 +381,9 @@ const fossilRepoName = ".fossil" var vcsFossil = &Cmd{ Name: "Fossil", Cmd: "fossil", - RootNames: []rootName{ - {filename: ".fslckout", isDir: false}, - {filename: "_FOSSIL_", isDir: false}, + Roots: []isVCSRoot{ + vcsFileRoot(".fslckout"), + vcsFileRoot("_FOSSIL_"), }, Scheme: []string{"https", "http"}, @@ -592,7 +592,7 @@ func FromDir(dir, srcRoot string) (repoDir string, vcsCmd *Cmd, err error) { origDir := dir for len(dir) > len(srcRoot) { for _, vcs := range vcsList { - if isVCSRoot(dir, vcs.RootNames) { + if isVCSRootDir(dir, vcs.Roots) { if vcsCmd == nil { // Record first VCS we find. vcsCmd = vcs @@ -631,22 +631,71 @@ func FromDir(dir, srcRoot string) (repoDir string, vcsCmd *Cmd, err error) { return repoDir, vcsCmd, nil } -// isVCSRoot identifies a VCS root by checking whether the directory contains -// any of the listed root names. -func isVCSRoot(dir string, rootNames []rootName) bool { - for _, root := range rootNames { - fi, err := os.Stat(filepath.Join(dir, root.filename)) - if err == nil && fi.IsDir() == root.isDir { +// isVCSRootDir reports whether dir is a VCS root according to roots. +func isVCSRootDir(dir string, roots []isVCSRoot) bool { + for _, root := range roots { + if root.isRoot(dir) { return true } } - return false } -type rootName struct { - filename string - isDir bool +type isVCSRoot interface { + isRoot(dir string) bool +} + +// vcsFileRoot identifies a VCS root by the presence of a regular file. +type vcsFileRoot string + +func (vfr vcsFileRoot) isRoot(dir string) bool { + fi, err := os.Stat(filepath.Join(dir, string(vfr))) + return err == nil && fi.Mode().IsRegular() +} + +// vcsDirRoot identifies a VCS root by the presence of a directory. +type vcsDirRoot string + +func (vdr vcsDirRoot) isRoot(dir string) bool { + fi, err := os.Stat(filepath.Join(dir, string(vdr))) + return err == nil && fi.IsDir() +} + +// vcsGitRoot identifies a Git root by the presence of a .git directory or a .git worktree file. +// See https://go.dev/issue/58218. +type vcsGitRoot struct{} + +func (vcsGitRoot) isRoot(dir string) bool { + path := filepath.Join(dir, ".git") + fi, err := os.Stat(path) + if err != nil { + return false + } + if fi.IsDir() { + return true + } + // Is it a git worktree file? + // The format is "gitdir: \n". + if !fi.Mode().IsRegular() || fi.Size() == 0 || fi.Size() > 4096 { + return false + } + raw, err := os.ReadFile(path) + if err != nil { + return false + } + rest, ok := strings.CutPrefix(string(raw), "gitdir:") + if !ok { + return false + } + gitdir := strings.TrimSpace(rest) + if gitdir == "" { + return false + } + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(dir, gitdir) + } + fi, err = os.Stat(gitdir) + return err == nil && fi.IsDir() } type vcsNotFoundError struct { diff --git a/src/cmd/go/internal/vcs/vcs_test.go b/src/cmd/go/internal/vcs/vcs_test.go index ab70e517e27..8da8eb2c11a 100644 --- a/src/cmd/go/internal/vcs/vcs_test.go +++ b/src/cmd/go/internal/vcs/vcs_test.go @@ -6,7 +6,6 @@ package vcs import ( "errors" - "fmt" "internal/testenv" "os" "path/filepath" @@ -215,40 +214,61 @@ func TestRepoRootForImportPath(t *testing.T) { // Test that vcs.FromDir correctly inspects a given directory and returns the // right VCS and repo directory. func TestFromDir(t *testing.T) { - tempDir := t.TempDir() - - for _, vcs := range vcsList { - for r, root := range vcs.RootNames { - vcsName := fmt.Sprint(vcs.Name, r) - dir := filepath.Join(tempDir, "example.com", vcsName, root.filename) - if root.isDir { - err := os.MkdirAll(dir, 0755) - if err != nil { - t.Fatal(err) - } - } else { - err := os.MkdirAll(filepath.Dir(dir), 0755) - if err != nil { - t.Fatal(err) - } - f, err := os.Create(dir) - if err != nil { - t.Fatal(err) - } - f.Close() - } - - wantRepoDir := filepath.Dir(dir) - gotRepoDir, gotVCS, err := FromDir(dir, tempDir) - if err != nil { - t.Errorf("FromDir(%q, %q): %v", dir, tempDir, err) - continue - } - if gotRepoDir != wantRepoDir || gotVCS.Name != vcs.Name { - t.Errorf("FromDir(%q, %q) = RepoDir(%s), VCS(%s); want RepoDir(%s), VCS(%s)", dir, tempDir, gotRepoDir, gotVCS.Name, wantRepoDir, vcs.Name) - } - } + tests := []struct { + name string + vcs string + root string + create func(path string) error + }{ + {"hg", "Mercurial", ".hg", mkdir}, + {"git_dir", "Git", ".git", mkdir}, + {"git_worktree", "Git", ".git", createGitWorktreeFile}, + {"bzr", "Bazaar", ".bzr", mkdir}, + {"svn", "Subversion", ".svn", mkdir}, + {"fossil_fslckout", "Fossil", ".fslckout", touch}, + {"fossil_FOSSIL_", "Fossil", "_FOSSIL_", touch}, } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + repoDir := filepath.Join(tempDir, "example.com") + if err := mkdir(repoDir); err != nil { + t.Fatal(err) + } + rootPath := filepath.Join(repoDir, tt.root) + if err := tt.create(rootPath); err != nil { + t.Fatal(err) + } + gotRepoDir, gotVCS, err := FromDir(repoDir, tempDir) + if err != nil { + t.Fatal(err) + } + if gotRepoDir != repoDir { + t.Errorf("RepoDir = %q, want %q", gotRepoDir, repoDir) + } + if gotVCS.Name != tt.vcs { + t.Errorf("VCS = %q, want %q", gotVCS.Name, tt.vcs) + } + }) + } +} + +func mkdir(path string) error { + return os.Mkdir(path, 0o755) +} + +func touch(path string) error { + return os.WriteFile(path, nil, 0o644) +} + +func createGitWorktreeFile(path string) error { + gitdir := path + ".worktree" + // gitdir must point to a real directory + if err := mkdir(gitdir); err != nil { + return err + } + return os.WriteFile(path, []byte("gitdir: "+gitdir+"\n"), 0o644) } func TestIsSecure(t *testing.T) { diff --git a/src/cmd/go/testdata/script/version_buildvcs_git_worktree.txt b/src/cmd/go/testdata/script/version_buildvcs_git_worktree.txt new file mode 100644 index 00000000000..651d1699cec --- /dev/null +++ b/src/cmd/go/testdata/script/version_buildvcs_git_worktree.txt @@ -0,0 +1,46 @@ +# Test that 'go build' stamps VCS information when building from a git worktree. +# See https://go.dev/issue/58218. + +[!git] skip +[short] skip + +# Create repo with a commit. +cd repo +exec git init +exec git config user.email g.o.p.h.e.r@go.dev +exec git config user.name Gopher +exec git add -A +exec git commit -m 'initial commit' + +# Sanity check: building from main repo includes VCS info. +go build -o main.exe . +go version -m main.exe +stdout '^\tbuild\tvcs=git$' +stdout '^\tbuild\tvcs.modified=false$' + +# Create a worktree and build from it. +exec git worktree add ../worktree HEAD +cd ../worktree +go build -o worktree.exe . +go version -m worktree.exe +stdout '^\tbuild\tvcs=git$' +stdout '^\tbuild\tvcs.modified=false$' + +# Verify that vcs.modified is detected in the worktree. +cp ../changed.go a.go +go build -o modified.exe . +go version -m modified.exe +stdout '^\tbuild\tvcs.modified=true$' + +-- repo/go.mod -- +module example.com/worktree + +go 1.18 +-- repo/a.go -- +package main + +func main() {} +-- changed.go -- +package main + +func main() { _ = 1 }