]> Cypherpunks repositories - gostls13.git/commitdiff
cmd/go/internal/vcs: support git worktrees
authorJosh Bleecher Snyder <josharian@gmail.com>
Wed, 14 Jan 2026 00:45:30 +0000 (16:45 -0800)
committerJosh Bleecher Snyder <josharian@gmail.com>
Fri, 23 Jan 2026 17:38:00 +0000 (09:38 -0800)
Fixes golang/go#58218

Change-Id: Ia155b26514557cf822caf37e727e5a410b0a36a6
Reviewed-on: https://go-review.googlesource.com/c/go/+/736260
Reviewed-by: Michael Pratt <mpratt@google.com>
Reviewed-by: Junyang Shao <shaojunyang@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Ian Lance Taylor <iant@golang.org>
src/cmd/go/internal/vcs/vcs.go
src/cmd/go/internal/vcs/vcs_test.go
src/cmd/go/testdata/script/version_buildvcs_git_worktree.txt [new file with mode: 0644]

index 98ed77d80d6fc89c30e317d4155052baa3d8f635..5613e79e7c18e61d322f5f4f057a4a7ca658446a 100644 (file)
@@ -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: <path>\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 {
index ab70e517e277f8d594258a7500c778676a48d232..8da8eb2c11a83075bee54f7c77ab9abe7851b5d2 100644 (file)
@@ -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()
+       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 _, 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()
+       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)
                        }
-
-                       wantRepoDir := filepath.Dir(dir)
-                       gotRepoDir, gotVCS, err := FromDir(dir, tempDir)
+                       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.Errorf("FromDir(%q, %q): %v", dir, tempDir, err)
-                               continue
+                               t.Fatal(err)
                        }
-                       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)
+                       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 (file)
index 0000000..651d169
--- /dev/null
@@ -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 }