Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/scm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,5 @@ type FileEntry struct {
Mode string `json:"mode"` // Git modes: "100644" regular, "100755" executable, "120000" symlink
Content string `json:"content"`
Type string `json:"type"`
Deleted bool `json:"deleted,omitempty"`
}
28 changes: 16 additions & 12 deletions backend/scm/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func (c *ghClient) PushFiles(ctx context.Context, owner, repo, branch, message s
}
parentTreeSHA := commit.GetTree().GetSHA()

treeEntries, err := c.createBlobs(ctx, owner, repo, files)
treeEntries, err := c.buildTreeEntries(ctx, owner, repo, files)
if err != nil {
return err
}
Expand Down Expand Up @@ -203,28 +203,32 @@ func (c *ghClient) PushFiles(ctx context.Context, owner, repo, branch, message s
return nil
}

func (c *ghClient) createBlobs(ctx context.Context, owner, repo string, files []scm.FileEntry) ([]*ghlib.TreeEntry, error) {
func (c *ghClient) buildTreeEntries(ctx context.Context, owner, repo string, files []scm.FileEntry) ([]*ghlib.TreeEntry, error) {
entries := make([]*ghlib.TreeEntry, len(files))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
for i, f := range files {
g.Go(func() error {
blob, _, err := c.client.Git.CreateBlob(ctx, owner, repo, &ghlib.Blob{
Content: new(f.Content),
Encoding: new("utf-8"),
})
if err != nil {
return mapErr(err)
}
if blob.GetSHA() == "" {
return fmt.Errorf("GitHub returned empty blob SHA")
var sha *string
if !f.Deleted {
blob, _, err := c.client.Git.CreateBlob(ctx, owner, repo, &ghlib.Blob{
Content: new(f.Content),
Encoding: new("utf-8"),
})
if err != nil {
return mapErr(err)
}
if blob.GetSHA() == "" {
return fmt.Errorf("GitHub returned empty blob SHA")
}
sha = blob.SHA
}
mode := f.Mode
entries[i] = &ghlib.TreeEntry{
Path: new(f.Path),
Mode: &mode,
Type: new("blob"),
SHA: blob.SHA,
SHA: sha,
}
return nil
})
Expand Down
63 changes: 55 additions & 8 deletions backend/scm/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ var _ = Describe("GitHub SCM client", func() {
Force bool `json:"force"`
}

pushStub := func(failAt string, lastRequest *updateRefBody) http.HandlerFunc {
pushStub := func(failAt string, lastRequest *updateRefBody, blobCount *int, treeEntries *[]map[string]any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/git/ref/"):
Expand All @@ -250,6 +250,9 @@ var _ = Describe("GitHub SCM client", func() {
json.NewEncoder(w).Encode(map[string]string{"message": "server error"})
return
}
if blobCount != nil {
*blobCount++
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"sha": "blobsha"})
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/git/trees"):
Expand All @@ -258,6 +261,13 @@ var _ = Describe("GitHub SCM client", func() {
json.NewEncoder(w).Encode(map[string]string{"message": "server error"})
return
}
if treeEntries != nil {
var body struct {
Tree []map[string]any `json:"tree"`
}
json.NewDecoder(r.Body).Decode(&body)
*treeEntries = body.Tree
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"sha": "newtreesha"})
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/git/commits"):
Expand Down Expand Up @@ -289,46 +299,83 @@ var _ = Describe("GitHub SCM client", func() {

It("commits all files and updates the ref to the new commit SHA", func() {
var refUpdate updateRefBody
cl := newClient(pushStub("", &refUpdate))
cl := newClient(pushStub("", &refUpdate, nil, nil))

Expect(pushFiles(cl)).To(Succeed())
Expect(refUpdate.SHA).To(Equal("newcommitsha"))
})

It("returns an error when getting the branch ref fails", func() {
err := pushFiles(newClient(pushStub("getRef", nil)))
err := pushFiles(newClient(pushStub("getRef", nil, nil, nil)))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get ref"))
})

It("returns an error when getting the head commit fails", func() {
err := pushFiles(newClient(pushStub("getCommit", nil)))
err := pushFiles(newClient(pushStub("getCommit", nil, nil, nil)))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get commit"))
})

It("returns an error when creating a blob fails", func() {
err := pushFiles(newClient(pushStub("createBlob", nil)))
err := pushFiles(newClient(pushStub("createBlob", nil, nil, nil)))
Expect(err).To(HaveOccurred())
})

It("returns an error when creating the tree fails", func() {
err := pushFiles(newClient(pushStub("createTree", nil)))
err := pushFiles(newClient(pushStub("createTree", nil, nil, nil)))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("create tree"))
})

It("returns an error when creating the commit fails", func() {
err := pushFiles(newClient(pushStub("createCommit", nil)))
err := pushFiles(newClient(pushStub("createCommit", nil, nil, nil)))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("create commit"))
})

It("returns an error when updating the ref fails", func() {
err := pushFiles(newClient(pushStub("updateRef", nil)))
err := pushFiles(newClient(pushStub("updateRef", nil, nil, nil)))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("update ref"))
})

It("does not create a blob and sends a nil SHA for a deleted file", func() {
var blobCount int
var entries []map[string]any
files := []scm.FileEntry{{Path: "remove.go", Mode: "100644", Content: "", Type: "blob", Deleted: true}}
cl := newClient(pushStub("", nil, &blobCount, &entries))

Expect(cl.PushFiles(context.Background(), "alice", "my-func", "main", "Delete file", files)).To(Succeed())
Expect(blobCount).To(Equal(0), "blob should not be created for a deleted file")
Expect(entries).To(HaveLen(1))
Expect(entries[0]["path"]).To(Equal("remove.go"))
Expect(entries[0]["sha"]).To(BeNil())
})

It("creates blobs only for non-deleted files in a mixed batch", func() {
var blobCount int
var entries []map[string]any
files := []scm.FileEntry{
{Path: "keep.go", Mode: "100644", Content: "package main", Type: "blob"},
{Path: "remove.go", Mode: "100644", Content: "", Type: "blob", Deleted: true},
}
cl := newClient(pushStub("", nil, &blobCount, &entries))

Expect(cl.PushFiles(context.Background(), "alice", "my-func", "main", "Partial delete", files)).To(Succeed())
Expect(blobCount).To(Equal(1), "only non-deleted files should create blobs")
Expect(entries).To(HaveLen(2))
var keepSHA, removeSHA any
for _, e := range entries {
if e["path"] == "keep.go" {
keepSHA = e["sha"]
} else {
removeSHA = e["sha"]
}
}
Expect(keepSHA).NotTo(BeNil())
Expect(removeSHA).To(BeNil())
})
})

Describe("InitRepo", func() {
Expand Down
101 changes: 101 additions & 0 deletions e2e/use-cases/edit/delete-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect } from '../../fixtures/authenticated-page';
import { navigateToEditPage } from '../../helpers/navigation';
import { E2E_USER, PRESEEDED_FUNC_NAME } from '../../helpers/constants';
import { seedRepo } from '../../helpers/fakegithub';

const BASE_FILES = [
{
path: 'func.yaml',
mode: '100644',
content: `name: ${PRESEEDED_FUNC_NAME}\nruntime: node\nnamespace: default\n`,
},
{
path: 'index.js',
mode: '100644',
content: 'module.exports = async (context) => context;',
},
];

test.beforeEach(async () => {
await seedRepo(
E2E_USER,
PRESEEDED_FUNC_NAME,
'main',
['serverless-function'],
[
...BASE_FILES,
{ path: 'delete-me.txt', mode: '100644', content: 'temporary file for deletion tests' },
],
);
});

test.afterEach(async () => {
await seedRepo(E2E_USER, PRESEEDED_FUNC_NAME, 'main', ['serverless-function'], BASE_FILES);
});

test.describe('Delete file', () => {
test('user deletes a file and saves the changes', async ({ page }) => {
await test.step('navigate to edit page', async () => {
await navigateToEditPage(page, PRESEEDED_FUNC_NAME);
const tree = page.getByRole('tree', { name: 'File tree' });
await expect(tree.getByText('delete-me.txt')).toBeVisible({ timeout: 15_000 });
});

await test.step('verify save button is disabled before changes', async () => {
await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeDisabled();
});

await test.step('hover to reveal action button and delete the file', async () => {
const tree = page.getByRole('tree', { name: 'File tree' });
await tree.getByText('delete-me.txt', { exact: true }).hover();
await page.getByRole('button', { name: 'delete-me.txt actions' }).click();
await page.getByRole('menuitem', { name: 'Delete File' }).click();
});

await test.step('verify delete-me.txt is removed from the tree', async () => {
const tree = page.getByRole('tree', { name: 'File tree' });
await expect(tree.getByText('delete-me.txt')).not.toBeVisible();
});

await test.step('verify save button is enabled after deletion', async () => {
await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeEnabled();
});

await test.step('save and verify success', async () => {
await page.getByRole('button', { name: 'Save & Deploy' }).click();

await expect(page.getByText('Pushed to GitHub. Deployment running...')).toBeVisible({
timeout: 10_000,
});

await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeDisabled({
timeout: 5_000,
});
});
});

test('deleting the selected file clears the editor', async ({ page }) => {
await test.step('navigate to edit page and select delete-me.txt', async () => {
await navigateToEditPage(page, PRESEEDED_FUNC_NAME);
const tree = page.getByRole('tree', { name: 'File tree' });
await expect(tree.getByText('delete-me.txt')).toBeVisible({ timeout: 15_000 });
await tree.getByText('delete-me.txt', { exact: true }).click();
await expect(page.locator('.monaco-editor').first()).toContainText('temporary file', {
timeout: 5_000,
});
});

await test.step('delete the selected file', async () => {
const tree = page.getByRole('tree', { name: 'File tree' });
await tree.getByText('delete-me.txt', { exact: true }).hover();
await page.getByRole('button', { name: 'delete-me.txt actions' }).click();
await page.getByRole('menuitem', { name: 'Delete File' }).click();
});

await test.step('verify the editor empty state is shown', async () => {
await expect(page.getByRole('heading', { name: 'Start editing' })).toBeVisible({
timeout: 5_000,
});
});
});
});
1 change: 1 addition & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface FileEntry {
mode: '100644' | '100755' | '120000';
content: string;
type: 'blob';
deleted?: boolean;
}

export type EnvVarSource = 'value' | 'secret' | 'configMap';
Expand Down
Loading