diff --git a/pkg/common/git/worktree.go b/pkg/common/git/worktree.go new file mode 100644 index 00000000000..3c1c441a1a6 --- /dev/null +++ b/pkg/common/git/worktree.go @@ -0,0 +1,299 @@ +package git + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +// ReconstituteWorktree detects whether workdir is a git worktree (i.e. its +// .git entry is a file rather than a directory) and, if so, creates a +// temporary directory that contains the working-tree files together with a +// self-contained .git/ directory that is usable inside a Linux container. +// +// The returned cleanup function must be called when the temporary directory is +// no longer needed. If workdir is not a worktree the original path is +// returned unchanged together with a no-op cleanup. +func ReconstituteWorktree(_ context.Context, workdir string) (string, func(), error) { + noop := func() {} + + gitPath := filepath.Join(workdir, ".git") + fi, err := os.Lstat(gitPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return workdir, noop, nil + } + return "", noop, err + } + + // Regular directory – not a worktree file. + if fi.IsDir() { + return workdir, noop, nil + } + + // .git must be a regular file for a worktree. + if !fi.Mode().IsRegular() { + return workdir, noop, nil + } + + log.Debugf("worktree: detected worktree .git file at %s", gitPath) + + // ------------------------------------------------------------------ + // 1. Parse the .git file to find the gitdir for this worktree. + // ------------------------------------------------------------------ + gitdir, err := parseGitFile(gitPath, workdir) + if err != nil { + return "", noop, err + } + + // ------------------------------------------------------------------ + // 2. Read /commondir to locate the main repository's git dir. + // ------------------------------------------------------------------ + commondir, err := readCommondir(gitdir) + if err != nil { + return "", noop, err + } + + // ------------------------------------------------------------------ + // 3. Create a temporary directory. + // ------------------------------------------------------------------ + tempdir, err := os.MkdirTemp("", "act-worktree-*") + if err != nil { + return "", noop, err + } + cleanup := func() { + os.RemoveAll(tempdir) + } + + // ------------------------------------------------------------------ + // 4. Copy the working tree (skip .git). + // ------------------------------------------------------------------ + if err := copyWorkingTree(workdir, tempdir); err != nil { + cleanup() + return "", noop, err + } + + // ------------------------------------------------------------------ + // 5. Build tempdir/.git from commondir with worktree overlay. + // ------------------------------------------------------------------ + destGit := filepath.Join(tempdir, ".git") + if err := os.MkdirAll(destGit, 0o755); err != nil { + cleanup() + return "", noop, err + } + + // 5a. Copy commondir into tempdir/.git, skipping worktrees/ and *.lock. + if err := copyCommondir(commondir, destGit); err != nil { + cleanup() + return "", noop, err + } + + // 5b. Overlay worktree-specific files from gitdir. + worktreeOverlayFiles := []string{"HEAD", "index", "ORIG_HEAD", "FETCH_HEAD"} + for _, name := range worktreeOverlayFiles { + src := filepath.Join(gitdir, name) + dst := filepath.Join(destGit, name) + if err := copyFileIfExists(src, dst); err != nil { + cleanup() + return "", noop, err + } + } + + // 5b (cont). Overlay logs/HEAD if present. + logsHeadSrc := filepath.Join(gitdir, "logs", "HEAD") + logsHeadDst := filepath.Join(destGit, "logs", "HEAD") + if err := os.MkdirAll(filepath.Dir(logsHeadDst), 0o755); err != nil { + cleanup() + return "", noop, err + } + if err := copyFileIfExists(logsHeadSrc, logsHeadDst); err != nil { + cleanup() + return "", noop, err + } + + // 5c. Remove stale commondir pointer if it was copied. + _ = os.Remove(filepath.Join(destGit, "commondir")) + + log.Debugf("worktree: reconstituted worktree to %s", tempdir) + + return tempdir, cleanup, nil +} + +// parseGitFile reads the .git file and returns the gitdir path it contains. +// If the stored path is relative it is resolved relative to workdir. +func parseGitFile(gitFilePath, workdir string) (string, error) { + data, err := os.ReadFile(gitFilePath) + if err != nil { + return "", err + } + // Use only the first line — .git files are single-line but be safe. + raw := strings.TrimSpace(string(data)) + line, _, _ := strings.Cut(raw, "\n") + const prefix = "gitdir:" + if !strings.HasPrefix(line, prefix) { + return "", errors.New("worktree: .git file does not contain a gitdir: line") + } + gitdir := strings.TrimSpace(line[len(prefix):]) + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Clean(filepath.Join(workdir, gitdir)) + } + return gitdir, nil +} + +// readCommondir reads /commondir and returns the resolved path. +func readCommondir(gitdir string) (string, error) { + commondirFile := filepath.Join(gitdir, "commondir") + data, err := os.ReadFile(commondirFile) + if err != nil { + // If commondir doesn't exist, the gitdir itself is the common dir. + if errors.Is(err, os.ErrNotExist) { + return gitdir, nil + } + return "", err + } + rel := strings.TrimSpace(string(data)) + if filepath.IsAbs(rel) { + return filepath.Clean(rel), nil + } + return filepath.Clean(filepath.Join(gitdir, rel)), nil +} + +// copyWorkingTree copies all working-tree files from src to dst, skipping +// the .git entry at the top level. +func copyWorkingTree(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + // Skip .git at the top level. When .git is a directory (normal repo) + // SkipDir skips the subtree. When .git is a file (worktree) we must + // return nil — SkipDir on a non-directory skips ALL remaining siblings. + if rel == ".git" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + destPath := filepath.Join(dst, rel) + + typ := d.Type() + + switch { + case typ.IsDir(): + return os.MkdirAll(destPath, 0o755) + + case typ.IsRegular(): + info, err := d.Info() + if err != nil { + return err + } + return copyFileWithMode(path, destPath, info.Mode()) + + case typ&fs.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(target, destPath) //nolint:gosec // G122: copying a known worktree, not following untrusted symlinks + + default: + return nil // ignore pipes, devices, etc. + } + }) +} + +// copyCommondir recursively copies commondir into destGit, skipping the +// worktrees/ subtree and any *.lock files. +func copyCommondir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + if rel == "." { + return nil + } + + // Skip worktrees/ subtree. + topLevel := strings.SplitN(rel, string(filepath.Separator), 2)[0] + if topLevel == "worktrees" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Skip *.lock files. + if !d.IsDir() && strings.HasSuffix(d.Name(), ".lock") { + return nil + } + + destPath := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(destPath, 0o755) + } + + if d.Type().IsRegular() { + info, err := d.Info() + if err != nil { + return err + } + return copyFileWithMode(path, destPath, info.Mode()) + } + + return nil + }) +} + +// copyFileIfExists copies src to dst if src exists; ENOENT is silently ignored. +func copyFileIfExists(src, dst string) error { + srcInfo, err := os.Lstat(src) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + return copyFileWithMode(src, dst, srcInfo.Mode()) +} + +// copyFileWithMode copies a regular file preserving its mode bits. +func copyFileWithMode(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/pkg/common/git/worktree_test.go b/pkg/common/git/worktree_test.go new file mode 100644 index 00000000000..a486b25e866 --- /dev/null +++ b/pkg/common/git/worktree_test.go @@ -0,0 +1,187 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeFile creates all necessary parent directories and writes content to path. +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestReconstituteWorktree_NotAWorktree(t *testing.T) { + dir := t.TempDir() + // .git is a directory – not a worktree file + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + writeFile(t, filepath.Join(dir, ".git", "HEAD"), "ref: refs/heads/main\n") + + result, cleanup, err := ReconstituteWorktree(context.Background(), dir) + require.NoError(t, err) + defer cleanup() + + assert.Equal(t, dir, result) +} + +func TestReconstituteWorktree_NoGit(t *testing.T) { + dir := t.TempDir() + // No .git at all + + result, cleanup, err := ReconstituteWorktree(context.Background(), dir) + require.NoError(t, err) + defer cleanup() + + assert.Equal(t, dir, result) +} + +func TestReconstituteWorktree_FakeWorktree(t *testing.T) { + base := t.TempDir() + + // Build main repository structure: + // base/main/.git/HEAD + // base/main/.git/config + // base/main/.git/refs/heads/main (a ref file) + // base/main/.git/worktrees/wt1/HEAD + // base/main/.git/worktrees/wt1/commondir (contains "../..") + // base/main/.git/worktrees/wt1/gitdir (path back to wt1/.git) + + mainGit := filepath.Join(base, "main", ".git") + wt1GitDir := filepath.Join(mainGit, "worktrees", "wt1") + + writeFile(t, filepath.Join(mainGit, "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(mainGit, "config"), "[core]\n\trepositoryformatversion = 0\n") + writeFile(t, filepath.Join(mainGit, "refs", "heads", "main"), "abc1234\n") + writeFile(t, filepath.Join(wt1GitDir, "HEAD"), "ref: refs/heads/feature\n") + writeFile(t, filepath.Join(wt1GitDir, "commondir"), "../..\n") + // gitdir file in the wt1GitDir just needs to exist; its contents point + // back to the worktree directory. + wt1Dir := filepath.Join(base, "wt1") + writeFile(t, filepath.Join(wt1GitDir, "gitdir"), filepath.Join(wt1Dir, ".git")+"\n") + + // Build worktree directory: + // base/wt1/.git (file pointing to main/.git/worktrees/wt1) + // base/wt1/somefile.txt + writeFile(t, filepath.Join(wt1Dir, ".git"), "gitdir: "+wt1GitDir+"\n") + writeFile(t, filepath.Join(wt1Dir, "somefile.txt"), "hello\n") + + result, cleanup, err := ReconstituteWorktree(context.Background(), wt1Dir) + require.NoError(t, err) + defer cleanup() + + assert.NotEqual(t, wt1Dir, result, "should return a new tempdir, not the original") + + // .git should now be a directory in the result + resultGitStat, err := os.Lstat(filepath.Join(result, ".git")) + require.NoError(t, err) + assert.True(t, resultGitStat.IsDir(), ".git in reconstituted dir should be a directory") + + // HEAD should be the worktree HEAD (overlaid from wt1GitDir), not main HEAD + headContent, err := os.ReadFile(filepath.Join(result, ".git", "HEAD")) + require.NoError(t, err) + assert.Equal(t, "ref: refs/heads/feature\n", string(headContent)) + + // config from commondir should be present + configContent, err := os.ReadFile(filepath.Join(result, ".git", "config")) + require.NoError(t, err) + assert.Contains(t, string(configContent), "repositoryformatversion") + + // somefile.txt should have been copied + somefileContent, err := os.ReadFile(filepath.Join(result, "somefile.txt")) + require.NoError(t, err) + assert.Equal(t, "hello\n", string(somefileContent)) + + // worktrees/ subtree should NOT be present in the reconstituted .git + _, err = os.Lstat(filepath.Join(result, ".git", "worktrees")) + assert.True(t, os.IsNotExist(err), "worktrees/ subtree should not be copied") + + // commondir pointer should have been removed + _, err = os.Lstat(filepath.Join(result, ".git", "commondir")) + assert.True(t, os.IsNotExist(err), "stale commondir file should be removed") +} + +func TestReconstituteWorktree_PreservesSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows – requires elevated privileges") + } + + dir := t.TempDir() + // Make a plain directory (not a worktree) with a symlink in it. + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + writeFile(t, filepath.Join(dir, ".git", "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(dir, "realfile.txt"), "contents\n") + require.NoError(t, os.Symlink("realfile.txt", filepath.Join(dir, "link.txt"))) + + // For this test we manually build a fake worktree so the reconstitution + // is triggered and we can assert symlinks are preserved. + base := t.TempDir() + mainGit := filepath.Join(base, "main", ".git") + wt1GitDir := filepath.Join(mainGit, "worktrees", "wt1") + + writeFile(t, filepath.Join(mainGit, "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(mainGit, "config"), "[core]\n\trepositoryformatversion = 0\n") + writeFile(t, filepath.Join(wt1GitDir, "HEAD"), "ref: refs/heads/feature\n") + writeFile(t, filepath.Join(wt1GitDir, "commondir"), "../..\n") + + wt1Dir := filepath.Join(base, "wt1") + require.NoError(t, os.MkdirAll(wt1Dir, 0o755)) + writeFile(t, filepath.Join(wt1Dir, ".git"), "gitdir: "+wt1GitDir+"\n") + writeFile(t, filepath.Join(wt1Dir, "realfile.txt"), "contents\n") + require.NoError(t, os.Symlink("realfile.txt", filepath.Join(wt1Dir, "link.txt"))) + + result, cleanup, err := ReconstituteWorktree(context.Background(), wt1Dir) + require.NoError(t, err) + defer cleanup() + + // Verify the symlink target is preserved + target, err := os.Readlink(filepath.Join(result, "link.txt")) + require.NoError(t, err) + assert.Equal(t, "realfile.txt", target) +} + +func TestReconstituteWorktree_RelativeGitdirInCommondir(t *testing.T) { + base := t.TempDir() + + // Layout: + // base/repo/.git/ (main git dir) + // base/repo/.git/worktrees/mywt/HEAD + // base/repo/.git/worktrees/mywt/commondir -> "../.." (relative: resolves to base/repo/.git) + // base/wt/.git (worktree file) + // base/wt/file.go + + repoGit := filepath.Join(base, "repo", ".git") + wtGitDir := filepath.Join(repoGit, "worktrees", "mywt") + + writeFile(t, filepath.Join(repoGit, "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(repoGit, "config"), "[core]\n\trepositoryformatversion = 0\n") + writeFile(t, filepath.Join(wtGitDir, "HEAD"), "ref: refs/heads/my-feature\n") + // commondir is relative: "../.." from base/repo/.git/worktrees/mywt resolves to base/repo/.git + writeFile(t, filepath.Join(wtGitDir, "commondir"), "../..\n") + + wtDir := filepath.Join(base, "wt") + writeFile(t, filepath.Join(wtDir, ".git"), "gitdir: "+wtGitDir+"\n") + writeFile(t, filepath.Join(wtDir, "file.go"), "package main\n") + + result, cleanup, err := ReconstituteWorktree(context.Background(), wtDir) + require.NoError(t, err) + defer cleanup() + + assert.NotEqual(t, wtDir, result) + + // HEAD should be the worktree HEAD + headContent, err := os.ReadFile(filepath.Join(result, ".git", "HEAD")) + require.NoError(t, err) + assert.Equal(t, "ref: refs/heads/my-feature\n", string(headContent)) + + // config from the main repo should be present + configContent, err := os.ReadFile(filepath.Join(result, ".git", "config")) + require.NoError(t, err) + assert.Contains(t, string(configContent), "repositoryformatversion") +} diff --git a/pkg/filecollector/file_collector.go b/pkg/filecollector/file_collector.go index c039926bbe0..1c23e99905b 100644 --- a/pkg/filecollector/file_collector.go +++ b/pkg/filecollector/file_collector.go @@ -145,6 +145,15 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." { return nil } + // Skip .git entries at any level. In normal repos .git is a directory + // (object store); in worktrees it is a file (gitdir pointer). Neither + // belongs in the container copy — they are git-internal metadata. + if len(split) > 0 && split[len(split)-1] == ".git" { + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } var entry *index.Entry if i != nil { entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/")) diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index 5d4277123ed..6cd3557b1c7 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -22,6 +22,7 @@ import ( "github.com/docker/go-connections/nat" "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/common/git" "github.com/nektos/act/pkg/container" "github.com/nektos/act/pkg/exprparser" "github.com/nektos/act/pkg/model" @@ -30,35 +31,65 @@ import ( // RunContext contains info about current job type RunContext struct { - Name string - Config *Config - Matrix map[string]interface{} - Run *model.Run - EventJSON string - Env map[string]string - GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field - ExtraPath []string - CurrentStep string - StepResults map[string]*model.StepResult - IntraActionState map[string]map[string]string - ExprEval ExpressionEvaluator - JobContainer container.ExecutionsEnvironment - ServiceContainers []container.ExecutionsEnvironment - OutputMappings map[MappableOutput]MappableOutput - JobName string - ActionPath string - Parent *RunContext - Masks []string - cleanUpJobContainer common.Executor - caller *caller // job calling this RunContext (reusable workflows) - Cancelled bool - nodeToolFullPath string + Name string + Config *Config + Matrix map[string]interface{} + Run *model.Run + EventJSON string + Env map[string]string + GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field + ExtraPath []string + CurrentStep string + StepResults map[string]*model.StepResult + IntraActionState map[string]map[string]string + ExprEval ExpressionEvaluator + JobContainer container.ExecutionsEnvironment + ServiceContainers []container.ExecutionsEnvironment + OutputMappings map[MappableOutput]MappableOutput + JobName string + ActionPath string + Parent *RunContext + Masks []string + cleanUpJobContainer common.Executor + caller *caller // job calling this RunContext (reusable workflows) + Cancelled bool + nodeToolFullPath string + worktreeCleanup func() + reconstitutedWorkdir string } func (rc *RunContext) AddMask(mask string) { rc.Masks = append(rc.Masks, mask) } +// effectiveWorkdir returns the path that should be used as the HOST-side +// working directory. When a git worktree has been reconstituted into a +// temporary directory that path is returned; otherwise the configured workdir +// is returned unchanged. +func (rc *RunContext) effectiveWorkdir() string { + if rc.reconstitutedWorkdir != "" { + return rc.reconstitutedWorkdir + } + return rc.Config.Workdir +} + +// reconstitute detects whether the configured workdir is a git worktree and, +// if so, creates a self-contained copy that can be mounted into a container. +func (rc *RunContext) reconstitute(ctx context.Context) { + logger := common.Logger(ctx) + if rc.worktreeCleanup != nil { + rc.worktreeCleanup() + rc.worktreeCleanup = nil + } + reconstituted, wtCleanup, wtErr := git.ReconstituteWorktree(ctx, rc.Config.Workdir) + if wtErr != nil { + logger.Warnf("Failed to reconstitute worktree, using original workdir: %v", wtErr) + } else { + rc.reconstitutedWorkdir = reconstituted + rc.worktreeCleanup = wtCleanup + } +} + type MappableOutput struct { StepID string OutputName string @@ -138,13 +169,14 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { } ext := container.LinuxContainerEnvironmentExtensions{} + workdir := rc.effectiveWorkdir() if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok { mounts := map[string]string{} // Permission issues? // binds = append(binds, hostEnv.ToolCache+":/opt/hostedtoolcache") binds = append(binds, hostEnv.GetActPath()+":"+ext.GetActPath()) - binds = append(binds, hostEnv.ToContainerPath(rc.Config.Workdir)+":"+ext.ToContainerPath(rc.Config.Workdir)) + binds = append(binds, hostEnv.ToContainerPath(workdir)+":"+ext.ToContainerPath(workdir)) return binds, mounts } mounts := map[string]string{ @@ -175,7 +207,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { if selinux.GetEnabled() { bindModifiers = ":z" } - binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers)) + binds = append(binds, fmt.Sprintf("%s:%s%s", workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers)) } else { mounts[name] = ext.ToContainerPath(rc.Config.Workdir) } @@ -212,11 +244,14 @@ func (rc *RunContext) startHostEnvironment() common.Executor { return err } toolCache := filepath.Join(cacheDir, "tool_cache") + + rc.reconstitute(ctx) + rc.JobContainer = &container.HostEnvironment{ Path: path, TmpDir: runnerTmp, ToolCache: toolCache, - Workdir: rc.Config.Workdir, + Workdir: rc.effectiveWorkdir(), ActPath: actPath, CleanUp: func() { os.RemoveAll(miscpath) @@ -282,6 +317,8 @@ func (rc *RunContext) startJobContainer() common.Executor { envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions + rc.reconstitute(ctx) + ext := container.LinuxContainerEnvironmentExtensions{} binds, mounts := rc.GetBindsAndMounts() @@ -684,6 +721,10 @@ func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) closeContainer() common.Executor { return func(ctx context.Context) error { + if rc.worktreeCleanup != nil { + rc.worktreeCleanup() + rc.worktreeCleanup = nil + } if rc.JobContainer != nil { return rc.JobContainer.Close()(ctx) } diff --git a/pkg/runner/step_action_remote.go b/pkg/runner/step_action_remote.go index 4c1ef353278..517f8b4cce4 100644 --- a/pkg/runner/step_action_remote.go +++ b/pkg/runner/step_action_remote.go @@ -164,7 +164,15 @@ func (sar *stepActionRemote) main() common.Executor { } eval := sar.RunContext.NewExpressionEvaluator(ctx) copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"])) - return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) + // When the workdir was reconstituted from a worktree, .git is a + // real directory in the temp copy. If a prior run (--reuse) left + // .git as a file in the container volume, docker cp fails trying + // to create entries inside a file. Remove it first. + if sar.RunContext.reconstitutedWorkdir != "" { + containerGit := path.Join(copyToPath, ".git") + _ = sar.RunContext.JobContainer.Exec([]string{"rm", "-rf", containerGit}, nil, "", "")(ctx) + } + return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.effectiveWorkdir()+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) } actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses))