Commit aacf9424 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'dmishunov-multiple-snippet-queries' into 'master'

Support per-blob queries in snippets

See merge request gitlab-org/gitlab!42809
parents f327577b 6c73ac27
...@@ -151,7 +151,7 @@ export default { ...@@ -151,7 +151,7 @@ export default {
this.newSnippet = false; this.newSnippet = false;
}, },
onSnippetFetch(snippetRes) { onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) { if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched(); this.onNewSnippetFetched();
} else { } else {
this.onExistingSnippetFetched(); this.onExistingSnippetFetched();
......
...@@ -23,6 +23,7 @@ export default { ...@@ -23,6 +23,7 @@ export default {
return { return {
ids: this.snippet.id, ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER, rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
}; };
}, },
update(data) { update(data) {
...@@ -79,8 +80,10 @@ export default { ...@@ -79,8 +80,10 @@ export default {
}, },
onContentUpdate(data) { onContentUpdate(data) {
const { path: blobPath } = this.blob; const { path: blobPath } = this.blob;
const { blobs } = data.snippets.edges[0].node; const {
const updatedBlobData = blobs.find(blob => blob.path === blobPath); blobs: { nodes: dataBlobs },
} = data.snippets.nodes[0];
const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData; return updatedBlobData.richData || updatedBlobData.plainData;
}, },
}, },
......
...@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; ...@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
export default { export default {
components: { components: {
...@@ -37,6 +38,7 @@ export default { ...@@ -37,6 +38,7 @@ export default {
}, },
apollo: { apollo: {
canCreateSnippet: { canCreateSnippet: {
fetchPolicy: fetchPolicies.NO_CACHE,
query() { query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
}, },
......
...@@ -12,6 +12,7 @@ fragment SnippetBase on Snippet { ...@@ -12,6 +12,7 @@ fragment SnippetBase on Snippet {
httpUrlToRepo httpUrlToRepo
sshUrlToRepo sshUrlToRepo
blobs { blobs {
nodes {
binary binary
name name
path path
...@@ -26,6 +27,7 @@ fragment SnippetBase on Snippet { ...@@ -26,6 +27,7 @@ fragment SnippetBase on Snippet {
...BlobViewer ...BlobViewer
} }
} }
}
userPermissions { userPermissions {
adminSnippet adminSnippet
updateSnippet updateSnippet
......
...@@ -16,7 +16,7 @@ function appFactory(el, Component) { ...@@ -16,7 +16,7 @@ function appFactory(el, Component) {
} }
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient({}, { batchMax: 1 }),
}); });
const { const {
......
...@@ -11,9 +11,16 @@ export const getSnippetMixin = { ...@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid, ids: this.snippetGid,
}; };
}, },
update: data => data.snippets.edges[0]?.node, update: data => {
const res = data.snippets.nodes[0];
if (res) {
res.blobs = res.blobs.nodes;
}
return res;
},
result(res) { result(res) {
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) { if (this.onSnippetFetch) {
this.onSnippetFetch(res); this.onSnippetFetch(res);
} }
......
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) { snippets(ids: $ids) {
edges { nodes {
node {
id id
blobs { blobs(paths: $paths) {
nodes {
path path
richData @include(if: $rich) richData @include(if: $rich)
plainData @skip(if: $rich) plainData @skip(if: $rich)
......
...@@ -4,8 +4,7 @@ ...@@ -4,8 +4,7 @@
query GetSnippetQuery($ids: [ID!]) { query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) { snippets(ids: $ids) {
edges { nodes {
node {
...SnippetBase ...SnippetBase
...SnippetProject ...SnippetProject
author { author {
...@@ -13,5 +12,4 @@ query GetSnippetQuery($ids: [ID!]) { ...@@ -13,5 +12,4 @@ query GetSnippetQuery($ids: [ID!]) {
} }
} }
} }
}
} }
# frozen_string_literal: true
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
required: false,
description: 'Paths of the blobs'
def resolve(**args)
authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
paths = Array(args.fetch(:paths, []))
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
def authorized_resource?(snippet)
Ability.allowed?(context[:current_user], :read_snippet, snippet)
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
end
end
end
end
...@@ -69,10 +69,11 @@ module Types ...@@ -69,10 +69,11 @@ module Types
null: false, null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' } deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
field :blobs, type: [Types::Snippets::BlobType], field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs', description: 'Snippet blobs',
calls_gitaly: true, calls_gitaly: true,
null: false null: true,
resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE, field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository', description: 'SSH URL to the snippet repository',
......
...@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated ...@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end end
def blob def blob
blobs.first return snippet.blob if snippet.empty_repo?
end
def blobs blobs.first
if snippet.empty_repo?
[snippet.blob]
else
snippet.blobs
end
end end
private private
......
...@@ -16250,7 +16250,32 @@ type Snippet implements Noteable { ...@@ -16250,7 +16250,32 @@ type Snippet implements Noteable {
""" """
Snippet blobs Snippet blobs
""" """
blobs: [SnippetBlob!]! blobs(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Paths of the blobs
"""
paths: [String!]
): SnippetBlobConnection
""" """
Timestamp this snippet was created Timestamp this snippet was created
...@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType { ...@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType {
previousPath: String previousPath: String
} }
"""
The connection type for SnippetBlob.
"""
type SnippetBlobConnection {
"""
A list of edges.
"""
edges: [SnippetBlobEdge]
"""
A list of nodes.
"""
nodes: [SnippetBlob]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SnippetBlobEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SnippetBlob
}
""" """
Represents how the blob content should be displayed Represents how the blob content should be displayed
""" """
......
...@@ -47549,24 +47549,69 @@ ...@@ -47549,24 +47549,69 @@
"name": "blobs", "name": "blobs",
"description": "Snippet blobs", "description": "Snippet blobs",
"args": [ "args": [
{
], "name": "paths",
"description": "Paths of the blobs",
"type": { "type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST", "kind": "LIST",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "NON_NULL", "kind": "NON_NULL",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "OBJECT", "kind": "SCALAR",
"name": "SnippetBlob", "name": "String",
"ofType": null "ofType": null
} }
} }
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
} }
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"ofType": null
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
...@@ -48220,6 +48265,118 @@ ...@@ -48220,6 +48265,118 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"description": "The connection type for SnippetBlob.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "SnippetBlobViewer", "name": "SnippetBlobViewer",
...@@ -2303,7 +2303,6 @@ Represents a snippet entry. ...@@ -2303,7 +2303,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `author` | User | The owner of the snippet | | `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 | | `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created | | `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet | | `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
......
...@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => { ...@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger // Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet. // apollo responses yet.
const loadSnippet = (...edges) => { const loadSnippet = (...nodes) => {
if (edges.length) { if (nodes.length) {
wrapper.setData({ wrapper.setData({
snippet: edges[0], snippet: nodes[0],
}); });
} }
wrapper.vm.onSnippetFetch({ wrapper.vm.onSnippetFetch({
data: { data: {
snippets: { snippets: {
edges, nodes,
}, },
}, },
}); });
......
...@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => { ...@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => { async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = { const apolloData = {
snippets: { snippets: {
edges: [ nodes: [
{ {
node: { blobs: {
blobs: snippetBlobs, nodes: snippetBlobs,
}, },
}, },
], ],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Snippets::BlobsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
context 'when user is not authorized' do
let(:other_user) { create(:user) }
it 'raises an error' do
expect do
resolve_blobs(snippet, user: other_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when using no filter' do
it 'returns all snippet blobs' do
expect(resolve_blobs(snippet).map(&:path)).to contain_exactly(*snippet.list_files)
end
end
context 'when using filters' do
context 'when paths is a single string' do
it 'returns an array of files' do
path = 'CHANGELOG'
expect(resolve_blobs(snippet, args: { paths: path }).first.path).to eq(path)
end
end
context 'when paths is an array of string' do
it 'returns an array of files' do
paths = ['CHANGELOG', 'README.md']
expect(resolve_blobs(snippet, args: { paths: paths }).map(&:path)).to contain_exactly(*paths)
end
end
end
end
def resolve_blobs(snippet, user: current_user, args: {})
resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
end
end
...@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
end end
describe 'blobs field' do
subject { described_class.fields['blobs'] }
it 'returns blobs' do
is_expected.to have_graphql_type(Types::Snippets::BlobType.connection_type)
is_expected.to have_graphql_resolver(Resolvers::Snippets::BlobsResolver)
end
end
context 'when restricted visibility level is set to public' do context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) } let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
...@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do
describe '#blobs' do describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) } let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] } let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') }
let(:paths) { [] }
let(:query) do
%(
{
snippets {
edges {
node {
blobs(paths: #{paths}) {
edges {
node {
name
path
}
}
}
}
}
}
}
)
end
subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json } subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
shared_examples 'an array' do shared_examples 'an array' do
it 'returns an array of snippet blobs' do it 'returns an array of snippet blobs' do
...@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array' it_behaves_like 'an array'
it 'contains the first blob from the snippet' do it 'contains the first blob from the snippet' do
expect(query_blobs.first['name']).to eq blob.name expect(query_blobs.first['node']['name']).to eq blob.name
expect(query_blobs.first['path']).to eq blob.path expect(query_blobs.first['node']['path']).to eq blob.path
end end
end end
...@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array' it_behaves_like 'an array'
it 'contains all the blobs from the repository' do it 'contains all the blobs from the repository' do
resulting_blobs_names = query_blobs.map { |b| b['name'] } resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name)) expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end end
context 'when specific path is set' do
let(:paths) { ['CHANGELOG'] }
it_behaves_like 'an array'
it 'returns specific files' do
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match(paths)
end
end
end end
end end
......
...@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do ...@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do
end end
end end
end end
describe '#blobs' do
let(:snippet) { personal_snippet }
subject { presenter.blobs }
context 'when snippet does not have a repository' do
it 'returns an array with one SnippetBlob' do
expect(subject.size).to eq(1)
expect(subject.first).to eq(snippet.blob)
end
end
context 'when snippet has a repository' do
let(:snippet) { create(:snippet, :repository, author: user) }
it 'returns an array with all repository blobs' do
expect(subject).to match_array(snippet.blobs)
end
end
end
end end
...@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do ...@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do
end end
shared_examples 'creates snippet' do shared_examples 'creates snippet' do
it 'returns the created Snippet' do it 'returns the created Snippet', :aggregate_failures do
expect do expect do
subject subject
end.to change { Snippet.count }.by(1) end.to change { Snippet.count }.by(1)
snippet = Snippet.last
created_file_1 = snippet.repository.blob_at('HEAD', file_1[:filePath])
created_file_2 = snippet.repository.blob_at('HEAD', file_2[:filePath])
expect(created_file_1.data).to match(file_1[:content])
expect(created_file_2.data).to match(file_2[:content])
expect(mutation_response['snippet']['title']).to eq(title) expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description) expect(mutation_response['snippet']['description']).to eq(description)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level) expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end end
context 'when action is invalid' do context 'when action is invalid' do
......
...@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do aggregate_failures do
expect(blob_to_update.data).to eq updated_content expect(blob_to_update.data).to eq updated_content
expect(blob_to_delete).to be_nil expect(blob_to_delete).to be_nil
expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
expect(mutation_response['snippet']['title']).to eq(updated_title) expect(mutation_response['snippet']['title']).to eq(updated_title)
expect(mutation_response['snippet']['description']).to eq(updated_description) expect(mutation_response['snippet']['description']).to eq(updated_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('public') expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
...@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do aggregate_failures do
expect(blob_at(updated_file).data).to eq blob_to_update.data expect(blob_at(updated_file).data).to eq blob_to_update.data
expect(blob_at(deleted_file).data).to eq blob_to_delete.data expect(blob_at(deleted_file).data).to eq blob_to_delete.data
expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
expect(mutation_response['snippet']['title']).to eq(original_title) expect(mutation_response['snippet']['title']).to eq(original_title)
expect(mutation_response['snippet']['description']).to eq(original_description) expect(mutation_response['snippet']['description']).to eq(original_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('private') expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
...@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do
end end
end end
def blob_in_mutation_response(filename)
mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
end
def blob_at(filename) def blob_at(filename)
snippet.repository.blob_at('HEAD', filename) snippet.repository.blob_at('HEAD', filename)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment