Skip to content

Commit 225502c

Browse files
authored
Only fetch archive of subdirectories in workspaces (#462)
* Only fetch archive of subdirectories in workspaces * Fetch additional files * Add a comment * Make fake file mux more generic * Copy additional files into workspaces before creating git repo * Add TODOs * Fix directory check on Windows * Hash local file path * Fix typo in docstring * More debug output * Try this * Remove debug output * Sort names before mounting them * Remove left-over slice * Remove duplication in copying of files * Hopefully fix copying * Download additional files on way up from workspace to root dir * Add a unit test for additional files * Fix on Windows * Debug output * Do not use os.FileSeparator for URLs * Update changelog entry * Disable download of only workspace by default, add flag * Add a docstring in schema
1 parent 6291577 commit 225502c

19 files changed

+804
-149
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ All notable changes to `src-cli` are documented in this file.
1313

1414
### Added
1515

16-
- Experimental: [`workspaces` in campaign specs](https://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#workspaces) is now available to allow users to define multiple workspaces in a single repository. [#442](https://github.com/sourcegraph/src-cli/pull/442)
16+
- Experimental (requires Sourcegraph 3.25 or later): [`workspaces` in campaign specs](https://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#workspaces) is now available to allow users to define multiple workspaces in a single repository. [#442](https://github.com/sourcegraph/src-cli/pull/442) and [#462](https://github.com/sourcegraph/src-cli/pull/462).
1717
- The `changesetTemplate.published` field can now also be used to address a specific changeset in a repository by adding `@branch-of-changeset` at the end of the pattern. See [#461](https://github.com/sourcegraph/src-cli/pull/461) for an example and details.
1818

1919
### Changed

cmd/src/campaigns_common.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ func campaignsExecute(ctx context.Context, out *output.Output, svc *campaigns.Se
237237

238238
fetcher := svc.NewRepoFetcher(flags.cacheDir, flags.cleanArchives)
239239
for _, task := range tasks {
240-
task.Archive = fetcher.Checkout(task.Repository)
240+
task.Archive = fetcher.Checkout(task.Repository, task.ArchivePathToFetch())
241241
}
242242

243243
opts := campaigns.ExecutorOpts{

internal/campaigns/bind_workspace.go

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"io/ioutil"
99
"os"
10+
"path"
1011
"path/filepath"
1112
"strings"
1213

@@ -21,12 +22,16 @@ type dockerBindWorkspaceCreator struct {
2122

2223
var _ WorkspaceCreator = &dockerBindWorkspaceCreator{}
2324

24-
func (wc *dockerBindWorkspaceCreator) Create(ctx context.Context, repo *graphql.Repository, steps []Step, zip string) (Workspace, error) {
25-
w, err := wc.unzipToWorkspace(ctx, repo, zip)
25+
func (wc *dockerBindWorkspaceCreator) Create(ctx context.Context, repo *graphql.Repository, steps []Step, archive RepoZip) (Workspace, error) {
26+
w, err := wc.unzipToWorkspace(ctx, repo, archive.Path())
2627
if err != nil {
2728
return nil, errors.Wrap(err, "unzipping the repository")
2829
}
2930

31+
if err := wc.copyToWorkspace(ctx, w, archive.AdditionalFilePaths()); err != nil {
32+
return nil, errors.Wrap(err, "copying additional files into workspace")
33+
}
34+
3035
return w, errors.Wrap(wc.prepareGitRepo(ctx, w), "preparing local git repo")
3136
}
3237

@@ -56,6 +61,44 @@ func (wc *dockerBindWorkspaceCreator) unzipToWorkspace(ctx context.Context, repo
5661
return &dockerBindWorkspace{dir: workspace}, nil
5762
}
5863

64+
func (wc *dockerBindWorkspaceCreator) copyToWorkspace(ctx context.Context, w *dockerBindWorkspace, files map[string]string) error {
65+
for name, src := range files {
66+
srcStat, err := os.Stat(src)
67+
if err != nil {
68+
return err
69+
}
70+
71+
if !srcStat.Mode().IsRegular() {
72+
return fmt.Errorf("%s is not a regular file", src)
73+
}
74+
75+
srcFile, err := os.Open(src)
76+
if err != nil {
77+
return err
78+
}
79+
80+
destPath := path.Join(w.dir, name)
81+
82+
destFile, err := prepareCopyDestinationFile(srcStat, destPath)
83+
if err != nil {
84+
return err
85+
}
86+
_, err = io.Copy(destFile, srcFile)
87+
if err != nil {
88+
return err
89+
}
90+
91+
if cerr := destFile.Close(); cerr != nil {
92+
return errors.Wrap(cerr, "closing destination file failed")
93+
}
94+
if cerr := srcFile.Close(); cerr != nil {
95+
return errors.Wrap(cerr, "closing source file failed")
96+
}
97+
}
98+
99+
return nil
100+
}
101+
59102
type dockerBindWorkspace struct {
60103
dir string
61104
}
@@ -151,29 +194,10 @@ func unzip(zipFile, dest string) error {
151194
continue
152195
}
153196

154-
if err := mkdirAll(dest, filepath.Dir(f.Name), 0777); err != nil {
155-
return err
156-
}
157-
158-
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
197+
outFile, err := prepareCopyDestinationFile(f.FileInfo(), fpath)
159198
if err != nil {
160199
return err
161200
}
162-
163-
// Since the container might not run as the same user, we need to ensure
164-
// that the file is globally writable. If the execute bit is normally
165-
// set on the zipped up file, let's ensure we propagate that to the
166-
// group and other permission bits too.
167-
if f.Mode()&0111 != 0 {
168-
if err := os.Chmod(outFile.Name(), 0777); err != nil {
169-
return err
170-
}
171-
} else {
172-
if err := os.Chmod(outFile.Name(), 0666); err != nil {
173-
return err
174-
}
175-
}
176-
177201
rc, err := f.Open()
178202
if err != nil {
179203
outFile.Close()
@@ -196,6 +220,37 @@ func unzip(zipFile, dest string) error {
196220
return nil
197221
}
198222

223+
type moder interface {
224+
Mode() os.FileMode
225+
}
226+
227+
func prepareCopyDestinationFile(sourceInfo moder, dest string) (*os.File, error) {
228+
if err := mkdirAll(filepath.Dir(dest), "", 0777); err != nil {
229+
return nil, err
230+
}
231+
232+
outFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, sourceInfo.Mode())
233+
if err != nil {
234+
return nil, errors.Wrap(err, "opening destination file failed")
235+
}
236+
237+
// Since the container might not run as the same user, we need to ensure
238+
// that the file is globally writable. If the execute bit is normally
239+
// set on the zipped up file, let's ensure we propagate that to the
240+
// group and other permission bits too.
241+
if sourceInfo.Mode()&0111 != 0 {
242+
err = os.Chmod(outFile.Name(), 0777)
243+
} else {
244+
err = os.Chmod(outFile.Name(), 0666)
245+
}
246+
if err != nil {
247+
outFile.Close()
248+
return nil, err
249+
}
250+
251+
return outFile, nil
252+
}
253+
199254
// Technically, this is a misnomer, since it might be a socket or block special,
200255
// but errPathExistsAsNonDir is just ugly for an internal type.
201256
type errPathExistsAsFile string

internal/campaigns/bind_workspace_test.go

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,22 @@ func TestDockerBindWorkspaceCreator_Create(t *testing.T) {
3030
DefaultBranch: &graphql.Branch{Name: "main", Target: graphql.Target{OID: "d34db33f"}},
3131
}
3232

33-
archive := mockRepoArchive{
34-
repo: repo,
35-
files: map[string]string{
36-
"README.md": "# Welcome to the README\n",
37-
},
33+
filesInZip := map[string]string{
34+
"README.md": "# Welcome to the README\n",
3835
}
3936

37+
fakeFilesTmpDir := workspaceTmpDir(t)
38+
4039
// Create a zip file for all the other tests to use.
41-
f, err := ioutil.TempFile(workspaceTmpDir(t), "repo-zip-*")
40+
f, err := ioutil.TempFile(fakeFilesTmpDir, "repo-zip-*")
4241
if err != nil {
4342
t.Fatal(err)
4443
}
4544
archivePath := f.Name()
4645
t.Cleanup(func() { os.Remove(archivePath) })
4746

4847
zw := zip.NewWriter(f)
49-
for name, body := range archive.files {
48+
for name, body := range filesInZip {
5049
f, err := zw.Create(name)
5150
if err != nil {
5251
t.Fatal(err)
@@ -58,11 +57,34 @@ func TestDockerBindWorkspaceCreator_Create(t *testing.T) {
5857
zw.Close()
5958
f.Close()
6059

60+
// Create "additional files" for the tests to use
61+
additionalFiles := map[string]string{
62+
".gitignore": "This is the gitignore\n",
63+
"another-file": "This is another file",
64+
}
65+
additionalFilePaths := map[string]string{}
66+
for name, content := range additionalFiles {
67+
f, err := ioutil.TempFile(fakeFilesTmpDir, name+"-*")
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
filePath := f.Name()
72+
t.Cleanup(func() { os.Remove(filePath) })
73+
74+
if _, err := f.Write([]byte(content)); err != nil {
75+
t.Fatal(err)
76+
}
77+
78+
additionalFilePaths[name] = filePath
79+
f.Close()
80+
}
81+
6182
t.Run("success", func(t *testing.T) {
6283
testTempDir := workspaceTmpDir(t)
6384

85+
archive := &fakeRepoArchive{mockPath: archivePath}
6486
creator := &dockerBindWorkspaceCreator{dir: testTempDir}
65-
workspace, err := creator.Create(context.Background(), repo, nil, archivePath)
87+
workspace, err := creator.Create(context.Background(), repo, nil, archive)
6688
if err != nil {
6789
t.Fatalf("unexpected error: %s", err)
6890
}
@@ -72,8 +94,8 @@ func TestDockerBindWorkspaceCreator_Create(t *testing.T) {
7294
t.Fatalf("error walking workspace: %s", err)
7395
}
7496

75-
if !cmp.Equal(archive.files, haveUnzippedFiles) {
76-
t.Fatalf("wrong files in workspace:\n%s", cmp.Diff(archive.files, haveUnzippedFiles))
97+
if !cmp.Equal(filesInZip, haveUnzippedFiles) {
98+
t.Fatalf("wrong files in workspace:\n%s", cmp.Diff(filesInZip, haveUnzippedFiles))
7799
}
78100
})
79101

@@ -89,11 +111,44 @@ func TestDockerBindWorkspaceCreator_Create(t *testing.T) {
89111
t.Cleanup(func() { os.Remove(badZipFile) })
90112
badZip.Close()
91113

114+
badArchive := &fakeRepoArchive{mockPath: badZipFile}
115+
92116
creator := &dockerBindWorkspaceCreator{dir: testTempDir}
93-
if _, err := creator.Create(context.Background(), repo, nil, badZipFile); err == nil {
117+
if _, err := creator.Create(context.Background(), repo, nil, badArchive); err == nil {
94118
t.Error("unexpected nil error")
95119
}
96120
})
121+
122+
t.Run("additional files", func(t *testing.T) {
123+
testTempDir := workspaceTmpDir(t)
124+
125+
archive := &fakeRepoArchive{
126+
mockPath: archivePath,
127+
mockAdditionalFilePaths: additionalFilePaths,
128+
}
129+
130+
creator := &dockerBindWorkspaceCreator{dir: testTempDir}
131+
workspace, err := creator.Create(context.Background(), repo, nil, archive)
132+
if err != nil {
133+
t.Fatalf("unexpected error: %s", err)
134+
}
135+
136+
haveUnzippedFiles, err := readWorkspaceFiles(workspace)
137+
if err != nil {
138+
t.Fatalf("error walking workspace: %s", err)
139+
}
140+
141+
wantFiles := map[string]string{}
142+
for name, content := range filesInZip {
143+
wantFiles[name] = content
144+
}
145+
for name, content := range additionalFiles {
146+
wantFiles[name] = content
147+
}
148+
if !cmp.Equal(wantFiles, haveUnzippedFiles) {
149+
t.Fatalf("wrong files in workspace:\n%s", cmp.Diff(wantFiles, haveUnzippedFiles))
150+
}
151+
})
97152
}
98153

99154
func TestMkdirAll(t *testing.T) {
@@ -250,7 +305,7 @@ func readWorkspaceFiles(workspace Workspace) (map[string]string, error) {
250305
return err
251306
}
252307

253-
if strings.HasPrefix(rel, ".git") {
308+
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(os.PathSeparator)) {
254309
return nil
255310
}
256311

@@ -275,3 +330,20 @@ func dirContains(dir, filename string) (bool, error) {
275330

276331
return false, nil
277332
}
333+
334+
var _ RepoZip = &fakeRepoArchive{}
335+
336+
type fakeRepoArchive struct {
337+
mockPath string
338+
mockAdditionalFilePaths map[string]string
339+
}
340+
341+
func (f *fakeRepoArchive) Fetch(context.Context) error { return nil }
342+
func (f *fakeRepoArchive) Close() error { return nil }
343+
func (f *fakeRepoArchive) Path() string { return f.mockPath }
344+
func (f *fakeRepoArchive) AdditionalFilePaths() map[string]string {
345+
if f.mockAdditionalFilePaths != nil {
346+
return f.mockAdditionalFilePaths
347+
}
348+
return map[string]string{}
349+
}

internal/campaigns/campaign_spec.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ type ImportChangeset struct {
6464
}
6565

6666
type WorkspaceConfiguration struct {
67-
RootAtLocationOf string `json:"rootAtLocationOf,omitempty" yaml:"rootAtLocationOf"`
68-
In string `json:"in,omitempty" yaml:"in"`
67+
RootAtLocationOf string `json:"rootAtLocationOf,omitempty" yaml:"rootAtLocationOf"`
68+
In string `json:"in,omitempty" yaml:"in"`
69+
OnlyFetchWorkspace bool `json:"onlyFetchWorkspace,omitempty" yaml:"onlyFetchWorkspace"`
6970

7071
glob glob.Glob
7172
}

internal/campaigns/executor.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ type Executor interface {
5656

5757
type Task struct {
5858
Repository *graphql.Repository
59-
Path string
59+
60+
// Path is the folder relative to the repository's root in which the steps
61+
// should be executed.
62+
Path string
63+
// OnlyFetchWorkspace determines whether the repository archive contains
64+
// the complete repository or just the files in Path (and additional files,
65+
// see RepoFetcher).
66+
// If Path is "" then this setting has no effect.
67+
OnlyFetchWorkspace bool
6068

6169
Steps []Step
6270
Outputs map[string]interface{}
@@ -67,6 +75,13 @@ type Task struct {
6775
Archive RepoZip `json:"-"`
6876
}
6977

78+
func (t *Task) ArchivePathToFetch() string {
79+
if t.OnlyFetchWorkspace {
80+
return t.Path
81+
}
82+
return ""
83+
}
84+
7085
func (t *Task) cacheKey() ExecutionCacheKey {
7186
return ExecutionCacheKey{t}
7287
}

0 commit comments

Comments
 (0)