├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── ginatra ├── config.ru ├── config.yml ├── ginatra.gemspec ├── lib ├── ginatra.rb ├── ginatra │ ├── config.rb │ ├── errors.rb │ ├── helpers.rb │ ├── logger.rb │ ├── repo.rb │ ├── repo_list.rb │ ├── repo_stats.rb │ └── version.rb ├── git │ ├── webby.rb │ └── webby │ │ ├── extensions.rb │ │ └── http_backend.rb └── sinatra │ └── partials.rb ├── public ├── css │ ├── application.css │ ├── custom.sass │ ├── lib │ │ └── highlight.css │ └── twbs.sass ├── favicon.ico ├── img │ ├── asterisk.svg │ ├── download.svg │ ├── edit.svg │ ├── exclamation-circle.svg │ ├── file.svg │ ├── folder.svg │ ├── hdd-o.svg │ ├── loader.svg │ ├── mail-forward.svg │ ├── minus-square.svg │ └── plus-square.svg └── js │ ├── application.js │ ├── custom.js │ └── lib │ ├── bootstrap.min.js │ ├── jquery.lazyload.min.js │ ├── jquery.min.js │ └── jquery.pjax.js ├── repos └── README.md ├── spec ├── ginatra │ ├── helpers_spec.rb │ ├── logger_spec.rb │ ├── repo_list_spec.rb │ ├── repo_spec.rb │ └── repo_stats_spec.rb ├── ginatra_spec.rb └── spec_helper.rb └── views ├── 404.erb ├── 500.erb ├── _footer.erb ├── _header.erb ├── _tree_nav.erb ├── atom.erb ├── blob.erb ├── commit.erb ├── empty_repo.erb ├── index.erb ├── layout.erb ├── log.erb ├── stats.erb └── tree.erb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | InstalledFiles 7 | _yardoc 8 | coverage 9 | doc/ 10 | lib/bundler/man 11 | pkg 12 | rdoc 13 | spec/reports 14 | test/tmp 15 | test/version_tmp 16 | tmp 17 | 18 | repos/ 19 | config.yml 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.3 3 | - 2.0 4 | - 2.1 5 | - 2.2 6 | notifications: 7 | email: 8 | on_success: never # default: change 9 | on_failure: change # default: always 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master 2 | 3 | * Your contribution here. 4 | 5 | ## 4.1.0 (2015-04-19) 6 | 7 | * Log warnings and errors. Add a new setting: `log_file` to customize log file 8 | location. 9 | * Fix server startup when using Ruby 1.9 10 | * Upgrade to Twitter Bootstrap 3. Make interface mobile-friendly and 11 | responsive. 12 | * Introduce a new setting: `sitename` to customize title. 13 | * Improve error handling for invalid custom configuration. (@rogermarlow) 14 | 15 | ## 4.0.2 (2015-01-15) 16 | 17 | * Ignore files in `git_dirs` by default and remove `ignored_files` setting. 18 | * Allow non git directories inside `git_dirs`. 19 | 20 | ## 4.0.1 (2015-01-08) 21 | 22 | * Fix `RACK_ENV` setting in CLI that prevented to properly start Ginatra in 23 | production mode. 24 | 25 | ## 4.0.0 Aurora (2015-01-07) 26 | 27 | initial release of 4.x version 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | In the spirit of open source software, **everyone** is encouraged to help 4 | improve this project. 5 | 6 | ## Your Pull Request 7 | 8 | To make it easy to review and understand your change please keep the following 9 | things in mind before submitting your pull request: 10 | 11 | * Work on the latest possible state of **ginatra/master** 12 | * Create a branch which is dedicated to your change 13 | * Test your changes before creating a pull request (`bundle exec rake`) 14 | * If possible write a test case which confirms your change 15 | * Don't mix several features or bug-fixes in one pull request 16 | * Create a meaningful commit message 17 | * Explain your change (i.e. with a link to the issue you are fixing) 18 | * Keep it simple: don't overcomplicate things and add extra dependencies unless 19 | you have a *special* reason 20 | 21 | **IMPORTANT**: 22 | 23 | Before you start working on a larger contribution, you should get in touch first 24 | through the issue tracker with your idea so that the project's developers can 25 | help out and possibly guide you. Coordinating up front makes it much easier to 26 | avoid frustration later on. 27 | 28 | # Thank you 29 | 30 | Each contribution is extremely helpful - thank you! 31 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ginatra (4.1.0) 5 | bootstrap-sass (= 3.3.3) 6 | rouge (~> 1.8.0) 7 | rugged (~> 0.21.4) 8 | sinatra (~> 1.4.6) 9 | sprockets (~> 3.0) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | autoprefixer-rails (5.1.10) 15 | execjs 16 | json 17 | backports (3.6.4) 18 | better_errors (1.1.0) 19 | coderay (>= 1.0.0) 20 | erubis (>= 2.6.6) 21 | binding_of_caller (0.7.2) 22 | debug_inspector (>= 0.0.1) 23 | bootstrap-sass (3.3.3) 24 | autoprefixer-rails (>= 5.0.0.1) 25 | sass (>= 3.2.19) 26 | coderay (1.1.0) 27 | debug_inspector (0.0.2) 28 | diff-lcs (1.2.5) 29 | erubis (2.7.0) 30 | execjs (2.5.2) 31 | json (1.8.2) 32 | multi_json (1.11.0) 33 | rack (1.6.0) 34 | rack-protection (1.5.3) 35 | rack 36 | rack-test (0.6.3) 37 | rack (>= 1.0) 38 | rake (10.4.2) 39 | rouge (1.8.0) 40 | rspec (3.2.0) 41 | rspec-core (~> 3.2.0) 42 | rspec-expectations (~> 3.2.0) 43 | rspec-mocks (~> 3.2.0) 44 | rspec-core (3.2.3) 45 | rspec-support (~> 3.2.0) 46 | rspec-expectations (3.2.1) 47 | diff-lcs (>= 1.2.0, < 2.0) 48 | rspec-support (~> 3.2.0) 49 | rspec-mocks (3.2.1) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.2.0) 52 | rspec-support (3.2.2) 53 | rugged (0.21.4) 54 | sass (3.4.13) 55 | sinatra (1.4.6) 56 | rack (~> 1.4) 57 | rack-protection (~> 1.4) 58 | tilt (>= 1.3, < 3) 59 | sinatra-contrib (1.4.2) 60 | backports (>= 2.0) 61 | multi_json 62 | rack-protection 63 | rack-test 64 | sinatra (~> 1.4.0) 65 | tilt (~> 1.3) 66 | sprockets (3.0.1) 67 | rack (~> 1.0) 68 | tilt (1.4.1) 69 | 70 | PLATFORMS 71 | ruby 72 | 73 | DEPENDENCIES 74 | better_errors (~> 1.1.0) 75 | binding_of_caller 76 | ginatra! 77 | rack-test 78 | rake 79 | rspec 80 | sinatra-contrib 81 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2012 Samuel Elliott 2 | Copyright (c) 2012-2015 Nihad Abbasov 3 | 4 | MIT License 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | --- 26 | 27 | Contains data from external sources: 28 | 29 | depuracao/git-webby (https://github.com/depuracao/git-webby) 30 | license: https://github.com/depuracao/git-webby/blob/master/LICENSE.txt 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ginatra 2 | 3 | [![Build Status](https://img.shields.io/travis/NARKOZ/ginatra/master.svg)](https://travis-ci.org/NARKOZ/ginatra) 4 | [![Code Climate](https://img.shields.io/codeclimate/github/NARKOZ/ginatra.svg)](https://codeclimate.com/github/NARKOZ/ginatra) 5 | [![Gem Version](https://img.shields.io/gem/v/ginatra.svg)](https://rubygems.org/gems/ginatra) 6 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/NARKOZ/ginatra/blob/master/LICENSE.txt) 7 | 8 | **Ginatra** is a simple web-based git repository browser built on Ruby Sinatra. 9 | 10 | [ [website](http://narkoz.github.io/ginatra) | 11 | [screenshots](http://narkoz.github.io/ginatra/screenshots) | 12 | [demo](http://narkoz.github.io/ginatra/demo) ] 13 | 14 | ## Features 15 | 16 | + Easy installation 17 | + Multiple repository support 18 | + Multiple branch/tag support 19 | + Commit history, diff, patch 20 | + Feeds in Atom format 21 | + Syntax highlighting 22 | + Repository stats 23 | + Smart HTTP support 24 | + [and more](http://narkoz.github.io/ginatra#features) 25 | 26 | ## Installation 27 | 28 | There are 2 ways to install Ginatra: as a packaged Ruby gem or as a Sinatra app. 29 | It's recommended to install it as a ruby gem, unless you know what you're doing. 30 | 31 | ### Ginatra gem 32 | 33 | Run the following command to install Ginatra from RubyGems: 34 | 35 | ```sh 36 | gem install ginatra -v 4.1.0 37 | ``` 38 | 39 | Create config file (see [Configuration](#configuration) section in README). 40 | 41 | Start the Ginatra server: 42 | 43 | ```sh 44 | ginatra run 45 | ``` 46 | 47 | By default Ginatra will run on `localhost:9797` 48 | 49 | ### Ginatra app 50 | 51 | Run the following commands to install Ginatra from source: 52 | 53 | ```sh 54 | git clone git://github.com/NARKOZ/ginatra.git 55 | cd ginatra/ 56 | git checkout v4.1.0 57 | bundle 58 | ``` 59 | 60 | Create config file or modify existing (see [Configuration](#configuration) section in README). 61 | 62 | Start the Ginatra server: 63 | 64 | ```sh 65 | ./bin/ginatra run 66 | ``` 67 | 68 | By default Ginatra will run on `localhost:9797` 69 | 70 | ## Configuration 71 | 72 | Create `~/.ginatra/config.yml` file with your own settings. See 73 | [`config.yml`](https://github.com/NARKOZ/ginatra/blob/master/config.yml) for a reference. 74 | 75 | `git_dirs` - Ginatra will look into these folders for git repositories. It's 76 | required to append `*` at the end of path. Example: `/home/Development/repos/*` 77 | 78 | `sitename` - name of the site. Used in the page title and header. 79 | 80 | `description` - description of web interface. Used in index page. 81 | 82 | `port` - port that Ginatra server will run at. 83 | 84 | `host` - host that Ginatra server will run at. 85 | 86 | `prefix` - prefix for the host serving Ginatra. Used when Ginatra is installed 87 | in subdirectory. 88 | 89 | `git_clone_enabled?` - enables smart HTTP support and allows to clone git 90 | repositories. 91 | 92 | `log_file` - location of the log file where Ginatra will log warnings and 93 | errors. If this setting doesn't present Ginatra will log out to the standard 94 | output (stdout). 95 | 96 | If you installed Ginatra as an app, you can change settings by editing 97 | `config.yml` file in root folder. 98 | 99 | You need to restart web server after applying changes to config file. 100 | 101 | ## CLI 102 | 103 | You can interact with Ginatra via CLI. The following commands are available: 104 | 105 | ```sh 106 | ginatra run # Starts Ginatra server 107 | ginatra stop # Stops Ginatra server 108 | ginatra status # Checks status of the Ginatra server (running or not) 109 | ginatra -v # Shows version of Ginatra 110 | ginatra -h # Lists available commands and their options 111 | ``` 112 | 113 | ## How to Contribute 114 | 115 | Open issues are labeled per perceived difficulty. See [contributing 116 | guidelines](https://github.com/NARKOZ/ginatra/blob/master/CONTRIBUTING.md). 117 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | desc "Clone test repository" 5 | task :clone_repo do 6 | repos_dir = File.expand_path('./repos') 7 | FileUtils.cd(repos_dir) do 8 | puts `git clone git://github.com/atmos/hancock-client.git test` 9 | end unless Dir.exists?("#{repos_dir}/test") 10 | end 11 | 12 | RSpec::Core::RakeTask.new(:spec) do |spec| 13 | spec.pattern = FileList['spec/**/*_spec.rb'] 14 | spec.rspec_opts = ['--color'] 15 | end 16 | 17 | task default: ['clone_repo', 'spec'] 18 | -------------------------------------------------------------------------------- /bin/ginatra: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift File.expand_path('../../lib', __FILE__) 4 | 5 | ENV['RACK_ENV'] ||= 'production' 6 | 7 | require 'ginatra/config' 8 | require 'optparse' 9 | 10 | options = { 11 | port: Ginatra.config.port, 12 | host: Ginatra.config.host 13 | } 14 | 15 | global_opts = OptionParser.new do |opts| 16 | opts.banner = "Usage: ginatra " 17 | 18 | opts.separator "" 19 | opts.separator "Options:" 20 | 21 | opts.on('-v', '--version', 'Print version and exit') do 22 | require_relative '../lib/ginatra/version' 23 | puts "Ginatra #{Ginatra::VERSION} #{Ginatra::RELEASE_NAME}" 24 | exit 25 | end 26 | 27 | opts.on_tail('-h', '--help', 'Show this help message') do 28 | puts opts 29 | exit 30 | end 31 | end 32 | 33 | server_opts = { 34 | 'run' => OptionParser.new do |opts| 35 | opts.banner = "Usage: ginatra run " 36 | 37 | opts.separator "" 38 | opts.separator "Options:" 39 | 40 | opts.on('-p', '--port PORT', 'Port to bind to (defaults from config file)') do |port| 41 | options[:port] = port.to_i 42 | end 43 | 44 | opts.on('-h', '--host HOST', 'Host address to bind to (defaults from config file)') do |host| 45 | options[:host] = host 46 | end 47 | 48 | opts.on 'Start Ginatra web server' do 49 | rack_config = File.expand_path("#{__FILE__}/../../config.ru") 50 | pid_file = File.expand_path("#{__FILE__}/../../ginatra.pid") 51 | 52 | system "rackup -D -E #{ENV['RACK_ENV']} -P #{pid_file} -p #{options[:port]} -o #{options[:host]} #{rack_config}" 53 | puts "Ginatra runs on #{options[:host]}:#{options[:port]}" 54 | end 55 | end, 56 | 57 | 'stop' => OptionParser.new do |opts| 58 | opts.banner = "Usage: ginatra stop" 59 | 60 | opts.on 'Stop Ginatra web server' do 61 | pid_file = File.expand_path("#{__FILE__}/../../ginatra.pid") 62 | 63 | if File.exist?(pid_file) 64 | pid = File.read(pid_file) 65 | Process.kill('INT', pid.to_i) 66 | File.delete(pid_file) 67 | puts "Stopped Ginatra web server" 68 | end 69 | end 70 | end, 71 | 72 | 'status' => OptionParser.new do |opts| 73 | opts.banner = "Usage: ginatra status" 74 | 75 | opts.on 'Get status of Ginatra web server' do 76 | pid_file = File.expand_path("#{__FILE__}/../../ginatra.pid") 77 | status = File.exist?(pid_file) ? "running.. (pid: #{File.read(pid_file).to_i})" : 'NOT running' 78 | puts "Ginatra is #{status}" 79 | end 80 | end 81 | } 82 | 83 | if ARGV.size.zero? 84 | puts global_opts 85 | 86 | %w(run stop status).each do |cmd| 87 | puts 88 | puts server_opts[cmd] 89 | end 90 | 91 | exit 1 92 | end 93 | 94 | global_opts.order! 95 | server_opts[ARGV.shift].order! 96 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'ginatra' 2 | require 'sprockets' 3 | require 'bootstrap-sass' 4 | 5 | map '/assets' do 6 | environment = Sprockets::Environment.new 7 | root_path = File.dirname __FILE__ 8 | environment.append_path "#{root_path}/public/js" 9 | environment.append_path "#{root_path}/public/css" 10 | 11 | environment.context_class.class_eval do 12 | def asset_path(path, options = {}) 13 | end 14 | end 15 | 16 | run environment 17 | end 18 | 19 | if Ginatra.config.git_clone_enabled? 20 | require 'mkmf' 21 | require 'git/webby' 22 | 23 | # Make the mkmf logger write file output to null 24 | if RUBY_VERSION[0] == '1' 25 | module Logging; @logfile = File::NULL; end 26 | else 27 | module MakeMakefile::Logging; @logfile = File::NULL; end 28 | end 29 | 30 | git_executable = find_executable 'git' 31 | raise 'Git executable not found in PATH' if git_executable.nil? 32 | root_path = File.dirname __FILE__ 33 | 34 | Git::Webby::HttpBackend.configure do |server| 35 | server.project_root = "#{root_path}/repos" 36 | server.git_path = git_executable 37 | server.get_any_file = true 38 | server.upload_pack = false 39 | server.receive_pack = false 40 | server.authenticate = false 41 | end 42 | 43 | run Rack::Cascade.new [Git::Webby::HttpBackend, Ginatra::App] 44 | else 45 | map '/' do 46 | run Ginatra::App 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # Default config file for Ginatra 2 | # Settings specified in '~/.ginatra/config.yml' will take precedence over these 3 | git_dirs: 4 | - ./repos/* 5 | sitename: Ginatra 6 | description: "My Git Repositories" 7 | port: 9797 8 | host: localhost 9 | prefix: "/" 10 | git_clone_enabled?: true 11 | log_file: "~/.ginatra/ginatra.log" 12 | -------------------------------------------------------------------------------- /ginatra.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'ginatra/version' 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = "ginatra" 7 | gem.version = Ginatra::VERSION 8 | gem.summary = "Web interface for git repositories" 9 | gem.description = "Git repository viewer with a rocking good web interface" 10 | gem.homepage = "https://github.com/narkoz/ginatra" 11 | gem.email = ["nihad@42na.in"] 12 | gem.authors = ["Nihad Abbasov", "Sam Elliott", "Ryan Bigg"] 13 | 14 | gem.files = `git ls-files`.split($/) - ['Gemfile.lock'] 15 | gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } 16 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 17 | gem.require_paths = ["lib"] 18 | 19 | gem.required_ruby_version = ">= 1.9" 20 | 21 | gem.add_dependency 'sinatra', '~> 1.4.6' 22 | gem.add_dependency 'rugged', '~> 0.21.4' 23 | gem.add_dependency 'rouge', '~> 1.8.0' 24 | gem.add_dependency 'sprockets', '~> 3.0' 25 | 26 | # Assets 27 | gem.add_dependency 'bootstrap-sass', '3.3.3' 28 | 29 | gem.add_development_dependency 'rake' 30 | gem.add_development_dependency 'rspec' 31 | gem.add_development_dependency 'rack-test' 32 | gem.add_development_dependency 'sinatra-contrib' 33 | gem.add_development_dependency 'better_errors', '~> 1.1.0' 34 | gem.add_development_dependency 'binding_of_caller' 35 | end 36 | -------------------------------------------------------------------------------- /lib/ginatra.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'sinatra/partials' 3 | require 'rouge' 4 | require 'ginatra/config' 5 | require 'ginatra/errors' 6 | require 'ginatra/logger' 7 | require 'ginatra/helpers' 8 | require 'ginatra/repo' 9 | require 'ginatra/repo_list' 10 | require 'ginatra/repo_stats' 11 | 12 | module Ginatra 13 | # The main application class. 14 | # Contains all the core application logic and mounted in +config.ru+ file. 15 | class App < Sinatra::Base 16 | include Logger 17 | helpers Helpers, Sinatra::Partials 18 | 19 | configure do 20 | set :host, Ginatra.config.host 21 | set :port, Ginatra.config.port 22 | set :public_folder, "#{settings.root}/../public" 23 | set :views, "#{settings.root}/../views" 24 | enable :dump_errors, :logging, :static 25 | end 26 | 27 | configure :development do 28 | # Use better errors in development 29 | require 'better_errors' 30 | use BetterErrors::Middleware 31 | BetterErrors.application_root = settings.root 32 | 33 | # Reload modified files in development 34 | require 'sinatra/reloader' 35 | register Sinatra::Reloader 36 | Dir["#{settings.root}/ginatra/*.rb"].each { |file| also_reload file } 37 | end 38 | 39 | def cache(obj) 40 | etag obj if settings.production? 41 | end 42 | 43 | not_found do 44 | erb :'404', layout: false 45 | end 46 | 47 | error Ginatra::RepoNotFound, Ginatra::InvalidRef, 48 | Rugged::OdbError, Rugged::ObjectError, Rugged::InvalidError do 49 | halt 404, erb(:'404', layout: false) 50 | end 51 | 52 | error 500 do 53 | erb :'500', layout: false 54 | end 55 | 56 | # The root route 57 | get '/' do 58 | @repositories = Ginatra::RepoList.list 59 | erb :index 60 | end 61 | 62 | # The atom feed of recent commits to a +repo+. 63 | # 64 | # This only returns commits to the +master+ branch. 65 | # 66 | # @param [String] repo the repository url-sanitised-name 67 | get '/:repo.atom' do 68 | @repo = RepoList.find(params[:repo]) 69 | @commits = @repo.commits 70 | 71 | if @commits.empty? 72 | return '' 73 | else 74 | cache "#{@commits.first.oid}/atom" 75 | content_type 'application/xml' 76 | erb :atom, layout: false 77 | end 78 | end 79 | 80 | # The html page for a +repo+. 81 | # 82 | # Shows the most recent commits in a log format. 83 | # 84 | # @param [String] repo the repository url-sanitised-name 85 | get '/:repo/?' do 86 | @repo = RepoList.find(params[:repo]) 87 | 88 | if @repo.branches.none? 89 | erb :empty_repo 90 | else 91 | params[:page] = 1 92 | params[:ref] = @repo.branch_exists?('master') ? 'master' : @repo.branches.first.name 93 | @commits = @repo.commits(params[:ref]) 94 | cache "#{@commits.first.oid}/log" 95 | @next_commits = !@repo.commits(params[:ref], 10, 10).nil? 96 | erb :log 97 | end 98 | end 99 | 100 | # The atom feed of recent commits to a certain branch of a +repo+. 101 | # 102 | # @param [String] repo the repository url-sanitised-name 103 | # @param [String] ref the repository ref 104 | get '/:repo/:ref.atom' do 105 | @repo = RepoList.find(params[:repo]) 106 | @commits = @repo.commits(params[:ref]) 107 | 108 | if @commits.empty? 109 | return '' 110 | else 111 | cache "#{@commits.first.oid}/atom/ref" 112 | content_type 'application/xml' 113 | erb :atom, layout: false 114 | end 115 | end 116 | 117 | # The html page for a given +ref+ of a +repo+. 118 | # 119 | # Shows the most recent commits in a log format. 120 | # 121 | # @param [String] repo the repository url-sanitised-name 122 | # @param [String] ref the repository ref 123 | get '/:repo/:ref' do 124 | @repo = RepoList.find(params[:repo]) 125 | @commits = @repo.commits(params[:ref]) 126 | cache "#{@commits.first.oid}/ref" if @commits.any? 127 | params[:page] = 1 128 | @next_commits = !@repo.commits(params[:ref], 10, 10).nil? 129 | erb :log 130 | end 131 | 132 | # The html page for a +repo+ stats. 133 | # 134 | # Shows information about repository branch. 135 | # 136 | # @param [String] repo the repository url-sanitised-name 137 | # @param [String] ref the repository ref 138 | get '/:repo/stats/:ref' do 139 | @repo = RepoList.find(params[:repo]) 140 | @stats = RepoStats.new(@repo, params[:ref]) 141 | erb :stats 142 | end 143 | 144 | # The patch file for a given commit to a +repo+. 145 | # 146 | # @param [String] repo the repository url-sanitised-name 147 | # @param [String] commit the repository commit 148 | get '/:repo/commit/:commit.patch' do 149 | content_type :txt 150 | repo = RepoList.find(params[:repo]) 151 | commit = repo.commit(params[:commit]) 152 | cache "#{commit.oid}/patch" 153 | diff = commit.parents.first.diff(commit) 154 | diff.patch 155 | end 156 | 157 | # The html representation of a commit. 158 | # 159 | # @param [String] repo the repository url-sanitised-name 160 | # @param [String] commit the repository commit 161 | get '/:repo/commit/:commit' do 162 | @repo = RepoList.find(params[:repo]) 163 | @commit = @repo.commit(params[:commit]) 164 | cache @commit.oid 165 | erb :commit 166 | end 167 | 168 | # The html representation of a tag. 169 | # 170 | # @param [String] repo the repository url-sanitised-name 171 | # @param [String] tag the repository tag 172 | get '/:repo/tag/:tag' do 173 | @repo = RepoList.find(params[:repo]) 174 | @commit = @repo.commit_by_tag(params[:tag]) 175 | cache "#{@commit.oid}/tag" 176 | erb :commit 177 | end 178 | 179 | # HTML page for a given tree in a given +repo+ 180 | # 181 | # @param [String] repo the repository url-sanitised-name 182 | # @param [String] tree the repository tree 183 | get '/:repo/tree/:tree' do 184 | @repo = RepoList.find(params[:repo]) 185 | @tree = @repo.find_tree(params[:tree]) 186 | cache @tree.oid 187 | 188 | @path = { 189 | blob: "#{params[:repo]}/blob/#{params[:tree]}", 190 | tree: "#{params[:repo]}/tree/#{params[:tree]}" 191 | } 192 | erb :tree, layout: !is_pjax? 193 | end 194 | 195 | # HTML page for a given tree in a given +repo+. 196 | # 197 | # This one supports a splat parameter so you can specify a path. 198 | # 199 | # @param [String] repo the repository url-sanitised-name 200 | # @param [String] tree the repository tree 201 | get '/:repo/tree/:tree/*' do 202 | @repo = RepoList.find(params[:repo]) 203 | @tree = @repo.find_tree(params[:tree]) 204 | cache "#{@tree.oid}/#{params[:splat].first}" 205 | 206 | @tree.walk(:postorder) do |root, entry| 207 | @tree = @repo.lookup entry[:oid] if "#{root}#{entry[:name]}" == params[:splat].first 208 | end 209 | 210 | @path = { 211 | blob: "#{params[:repo]}/blob/#{params[:tree]}/#{params[:splat].first}", 212 | tree: "#{params[:repo]}/tree/#{params[:tree]}/#{params[:splat].first}" 213 | } 214 | erb :tree, layout: !is_pjax? 215 | end 216 | 217 | # HTML page for a given blob in a given +repo+ 218 | # 219 | # @param [String] repo the repository url-sanitised-name 220 | # @param [String] tree the repository tree 221 | get '/:repo/blob/:blob' do 222 | @repo = RepoList.find(params[:repo]) 223 | @tree = @repo.lookup(params[:tree]) 224 | 225 | @tree.walk(:postorder) do |root, entry| 226 | @blob = entry if "#{root}#{entry[:name]}" == params[:splat].first 227 | end 228 | 229 | cache @blob[:oid] 230 | erb :blob, layout: !is_pjax? 231 | end 232 | 233 | # HTML page for a given blob in a given repo. 234 | # 235 | # Uses a splat param to specify a blob path. 236 | # 237 | # @param [String] repo the repository url-sanitised-name 238 | # @param [String] tree the repository tree 239 | get '/:repo/blob/:tree/*' do 240 | @repo = RepoList.find(params[:repo]) 241 | @tree = @repo.find_tree(params[:tree]) 242 | 243 | @tree.walk(:postorder) do |root, entry| 244 | @blob = entry if "#{root}#{entry[:name]}" == params[:splat].first 245 | end 246 | 247 | cache "#{@blob[:oid]}/#{@tree.oid}" 248 | erb :blob, layout: !is_pjax? 249 | end 250 | 251 | # HTML page for a raw blob contents in a given repo. 252 | # 253 | # Uses a splat param to specify a blob path. 254 | # 255 | # @param [String] repo the repository url-sanitised-name 256 | # @param [String] tree the repository tree 257 | get '/:repo/raw/:tree/*' do 258 | @repo = RepoList.find(params[:repo]) 259 | @tree = @repo.find_tree(params[:tree]) 260 | 261 | @tree.walk(:postorder) do |root, entry| 262 | @blob = entry if "#{root}#{entry[:name]}" == params[:splat].first 263 | end 264 | 265 | cache "#{@blob[:oid]}/#{@tree.oid}/raw" 266 | blob = @repo.find_blob @blob[:oid] 267 | if blob.binary? 268 | content_type 'application/octet-stream' 269 | blob.text 270 | else 271 | content_type :txt 272 | blob.text 273 | end 274 | end 275 | 276 | # Pagination route for the commits to a given ref in a +repo+. 277 | # 278 | # @param [String] repo the repository url-sanitised-name 279 | # @param [String] ref the repository ref 280 | get '/:repo/:ref/page/:page' do 281 | pass unless params[:page] =~ /\A\d+\z/ 282 | params[:page] = params[:page].to_i 283 | @repo = RepoList.find(params[:repo]) 284 | @commits = @repo.commits(params[:ref], 10, (params[:page] - 1) * 10) 285 | cache "#{@commits.first.oid}/page/#{params[:page]}/ref/#{params[:ref]}" if @commits.any? 286 | @next_commits = !@repo.commits(params[:ref], 10, params[:page] * 10).nil? 287 | if params[:page] - 1 > 0 288 | @previous_commits = !@repo.commits(params[:ref], 10, (params[:page] - 1) * 10).empty? 289 | end 290 | erb :log 291 | end 292 | 293 | end # App 294 | end # Ginatra 295 | -------------------------------------------------------------------------------- /lib/ginatra/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'ostruct' 3 | 4 | module Ginatra 5 | def self.config 6 | @config ||= OpenStruct.new load_config 7 | end 8 | 9 | # Loads the configuration and merges it with 10 | # custom configuration if necessary. 11 | # 12 | # @return [Hash] config a hash of the configuration options 13 | def self.load_config 14 | current_path = File.expand_path(File.dirname(__FILE__)) 15 | custom_config_file = File.expand_path("~/.ginatra/config.yml") 16 | default_config_file = File.expand_path("#{current_path}/../../config.yml") 17 | 18 | # Our own file should be there and we don't need to check its syntax 19 | abort 'ginatra config file #{default_config_file} is missing.' unless File.exists?(default_config_file) 20 | final_config = YAML.load_file(default_config_file) 21 | 22 | # User config file may not exist or be broken 23 | if File.exists?(custom_config_file) 24 | begin 25 | custom_config = YAML.load_file(custom_config_file) 26 | rescue Psych::SyntaxError => ex 27 | puts "Cannot parse your config file #{ex.message}." 28 | custom_config = {} 29 | end 30 | final_config.merge!(custom_config) 31 | else 32 | puts "User config file #{custom_config_file} absent. Will only see repos in #{final_config["git_dirs"].join(", ")}." 33 | end 34 | 35 | final_config 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ginatra/errors.rb: -------------------------------------------------------------------------------- 1 | module Ginatra 2 | # A standard error class for inheritance. 3 | class Error < StandardError; end 4 | 5 | # Raised when repo not found in list. 6 | class RepoNotFound < Error; end 7 | 8 | # Raised when repo ref not found. 9 | class InvalidRef < Error; end 10 | end 11 | -------------------------------------------------------------------------------- /lib/ginatra/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | 3 | module Ginatra 4 | # Helpers used in the views, and not only. 5 | module Helpers 6 | # Escapes string to HTML entities 7 | def h(text) 8 | Rack::Utils.escape_html(text) 9 | end 10 | 11 | # Checks X-PJAX header 12 | def is_pjax? 13 | request.env['HTTP_X_PJAX'] 14 | end 15 | 16 | # Repository stats for a given repo 17 | def repo_stats(repo) 18 | ref = params[:ref] || params[:tag] || params[:tree] || 'master' 19 | @stats ||= Ginatra::RepoStats.new(repo, ref) 20 | end 21 | 22 | # Sets title for pages 23 | def title(*args) 24 | @title ||= [] 25 | @title_options ||= { headline: nil, sitename: h(Ginatra.config.sitename) } 26 | options = args.last.is_a?(Hash) ? args.pop : {} 27 | 28 | @title += args 29 | @title_options.merge!(options) 30 | 31 | t = @title.clone 32 | t << @title_options[:headline] 33 | t << @title_options[:sitename] 34 | t.compact.join ' - ' 35 | end 36 | 37 | # Constructs the URL used in the layout's base tag 38 | def prefix_url(rest_of_url='') 39 | prefix = Ginatra.config.prefix.to_s 40 | 41 | if prefix.length > 0 && prefix[-1].chr == '/' 42 | prefix.chop! 43 | end 44 | 45 | "#{prefix}/#{rest_of_url}" 46 | end 47 | 48 | # Returns hint to set repository description 49 | def empty_description_hint_for(repo) 50 | return '' unless repo.description.empty? 51 | hint_text = "Edit `#{repo.path}description` file to set the repository description." 52 | "hint" 53 | end 54 | 55 | # Returns file icon depending on filemode 56 | def file_icon(filemode) 57 | case filemode 58 | # symbolic link (120000) 59 | when 40960 then "symbolic link" 60 | # executable file (100755) 61 | when 33261 then "executable file" 62 | else "file" 63 | end 64 | end 65 | 66 | # Masks original email 67 | def secure_mail(email) 68 | local, domain = email.split('@') 69 | "#{local[0..3]}...@#{domain}" 70 | end 71 | 72 | # Takes an email and returns an image tag with gravatar 73 | # 74 | # @param [String] email the email address 75 | # @param [Hash] options alt, class and size options for image tag 76 | # @return [String] html image tag 77 | def gravatar_image_tag(email, options={}) 78 | alt = options.fetch(:alt, email.gsub(/@\S*/, '')) 79 | size = options.fetch(:size, 40) 80 | url = "https://secure.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?s=#{size}" 81 | 82 | if options[:lazy] 83 | placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 84 | tag = "#{h alt}" 85 | else 86 | tag = "#{h alt}" 87 | end 88 | 89 | tag 90 | end 91 | 92 | # Reformats the date into a user friendly date with html entities 93 | # 94 | # @param [#strftime] date object to format nicely 95 | # @return [String] html string formatted using 96 | # +"%b %d, %Y – %H:%M"+ 97 | def nicetime(date) 98 | date.strftime("%b %d, %Y – %H:%M") 99 | end 100 | 101 | # Returns an html time tag for the given time 102 | # 103 | # @param [Time] time object 104 | # @return [String] time tag formatted using 105 | # +"%B %d, %Y %H:%M"+ 106 | def time_tag(time) 107 | datetime = time.strftime('%Y-%m-%dT%H:%M:%S%z') 108 | title = time.strftime('%Y-%m-%d %H:%M:%S') 109 | "" 110 | end 111 | 112 | # Returns a string including the link to download a patch for a certain 113 | # commit to the repo 114 | # 115 | # @param [Rugged::Commit] commit the commit you want a patch for 116 | # @param [String] repo_param the url-sanitised name for the repo 117 | # (for the link path) 118 | # 119 | # @return [String] the HTML link to the patch 120 | def patch_link(commit, repo_param) 121 | patch_url = prefix_url("#{repo_param}/commit/#{commit.oid}.patch") 122 | "Download Patch" 123 | end 124 | 125 | # Spits out a HTML link to the atom feed for a given ref of a given repo 126 | # 127 | # @param [Sting] repo_param the url-sanitised-name of a given repo 128 | # @param [String] ref the ref to link to. 129 | # 130 | # @return [String] the HTML containing the link to the feed. 131 | def atom_feed_url(repo_param, ref=nil) 132 | ref.nil? ? prefix_url("#{repo_param}.atom") : prefix_url("#{repo_param}/#{ref}.atom") 133 | end 134 | 135 | # Returns a HTML (+
    +) list of the files modified in a given commit. 136 | # 137 | # It includes classes for added/modified/deleted and also anchor links 138 | # to the diffs for further down the page. 139 | # 140 | # @param [Rugged::Commit] commit the commit you want the list of files for 141 | # 142 | # @return [String] a +
      + with lots of +
    • + children. 143 | def file_listing(diff) 144 | list = [] 145 | diff.deltas.each_with_index do |delta, index| 146 | if delta.deleted? 147 | list << "
    • deleted #{delta.new_file[:path]}
    • " 148 | elsif delta.added? 149 | list << "
    • added #{delta.new_file[:path]}
    • " 150 | elsif delta.modified? 151 | list << "
    • modified #{delta.new_file[:path]}
    • " 152 | end 153 | end 154 | "
        #{list.join}
      " 155 | end 156 | 157 | # Highlights commit diff 158 | # 159 | # @param [Rugged::Hunk] diff hunk for highlighting 160 | # 161 | # @return [String] highlighted HTML.code 162 | def highlight_diff(hunk) 163 | lines = [] 164 | lines << hunk.header 165 | 166 | hunk.each_line do |line| 167 | if line.context? 168 | lines << " #{line.content}" 169 | elsif line.deletion? 170 | lines << "- #{line.content}" 171 | elsif line.addition? 172 | lines << "+ #{line.content}" 173 | end 174 | end 175 | 176 | formatter = Rouge::Formatters::HTML.new 177 | lexer = Rouge::Lexers::Diff.new 178 | 179 | source = lines.join 180 | encoding = source.encoding 181 | source = source.force_encoding(Encoding::UTF_8) 182 | 183 | hd = formatter.format lexer.lex(source) 184 | hd.force_encoding encoding 185 | end 186 | 187 | # Highlights blob source 188 | # 189 | # @param [Rugged::Blob] blob to highlight source 190 | # 191 | # @return [String] highlighted HTML.code 192 | def highlight_source(source, filename='') 193 | source = source.force_encoding(Encoding::UTF_8) 194 | formatter = Rouge::Formatters::HTML.new(line_numbers: true, wrap: false) 195 | lexer = Rouge::Lexer.guess_by_filename(filename) 196 | 197 | if lexer == Rouge::Lexers::PlainText 198 | lexer = Rouge::Lexer.guess_by_source(source) || Rouge::Lexers::PlainText 199 | end 200 | 201 | formatter.format lexer.lex(source) 202 | end 203 | 204 | # Formats the text to remove multiple spaces and newlines, and then inserts 205 | # HTML linebreaks. 206 | # 207 | # Borrowed from Rails: ActionView::Helpers::TextHelper#simple_format 208 | # and simplified to just use

      tags without any options, then modified 209 | # more later. 210 | # 211 | # @param [String] text the text you want formatted 212 | # 213 | # @return [String] the formatted text 214 | def simple_format(text) 215 | text.gsub!(/ +/, " ") 216 | text.gsub!(/\r\n?/, "\n") 217 | text.gsub!(/\n/, "
      \n") 218 | text 219 | end 220 | 221 | # Truncates a given text to a certain number of letters, including a special ending if needed. 222 | # 223 | # @param [String] text the text to truncate 224 | # @option options [Integer] :length (30) the length you want the output string 225 | # @option options [String] :omission ("...") the string to show an omission. 226 | # 227 | # @return [String] the truncated text. 228 | def truncate(text, options={}) 229 | options[:length] ||= 30 230 | options[:omission] ||= "..." 231 | 232 | if text 233 | l = options[:length] - options[:omission].length 234 | chars = text 235 | stop = options[:separator] ? (chars.rindex(options[:separator], l) || l) : l 236 | (chars.length > options[:length] ? chars[0...stop] + options[:omission] : text).to_s 237 | end 238 | end 239 | 240 | # Returns the rfc representation of a date, for use in the atom feeds. 241 | # 242 | # @param [DateTime] datetime the date to format 243 | # @return [String] the formatted datetime 244 | def rfc_date(datetime) 245 | datetime.strftime("%Y-%m-%dT%H:%M:%SZ") # 2003-12-13T18:30:02Z 246 | end 247 | 248 | # Returns the Hostname of the given install, for use in the atom feeds. 249 | # 250 | # @return [String] the hostname of the server. Respects HTTP-X-Forwarded-For 251 | def hostname 252 | (request.env['HTTP_X_FORWARDED_SERVER'] =~ /[a-z]*/) ? request.env['HTTP_X_FORWARDED_SERVER'] : request.env['HTTP_HOST'] 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/ginatra/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'fileutils' 3 | 4 | module Ginatra 5 | module Logger 6 | def logger 7 | Logger.logger 8 | end 9 | 10 | def self.logger 11 | return @logger if @logger 12 | 13 | if Ginatra.config.log_file 14 | log_file = File.expand_path(Ginatra.config.log_file) 15 | else 16 | log_file = STDOUT 17 | end 18 | 19 | unless log_file == STDOUT 20 | parent_dir, _separator, _filename = log_file.rpartition('/') 21 | FileUtils.mkdir_p parent_dir 22 | FileUtils.touch log_file 23 | end 24 | 25 | @logger = ::Logger.new log_file 26 | @logger.level = ::Logger::WARN 27 | @logger.formatter = proc do |severity, datetime, progname, msg| 28 | "[#{datetime} ##{Process.pid}] #{severity}: #{msg}\n" 29 | end 30 | @logger 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ginatra/repo.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | 3 | module Ginatra 4 | class Repo 5 | attr_reader :name, :param, :description 6 | 7 | # Create a new repository, and sort out clever stuff including assigning 8 | # the param, the name and the description. 9 | # 10 | # @param [String] path a path to the repository you want created 11 | # @return [Ginatra::Repo] a repository instance 12 | def initialize(path) 13 | @repo = Rugged::Repository.new(path) 14 | @param = File.split(path).last 15 | @name = @param 16 | @description = '' 17 | if File.exists?("#{@repo.path}description") 18 | @description = File.read("#{@repo.path}description").strip 19 | @description = '' if @description.match(/\AUnnamed repository;/) 20 | end 21 | end 22 | 23 | # Return a commit corresponding to sha in the repo. 24 | # 25 | # @param [String] sha the commit id or tag name 26 | # @return [Rugged::Commit] the commit object 27 | def commit(sha) 28 | @repo.lookup(sha) 29 | end 30 | 31 | # Return a commit corresponding to tag in the repo. 32 | def commit_by_tag(name) 33 | target = @repo.ref("refs/tags/#{name}").target 34 | 35 | if target.is_a? Rugged::Tag::Annotation 36 | target = target.target 37 | end 38 | 39 | target 40 | end 41 | 42 | # Return a list of commits in a certain branch, including pagination options and all the refs. 43 | # 44 | # @param [String] start the branch to look for commits in 45 | # @param [Integer] max_count the maximum count of commits 46 | # @param [Integer] skip the number of commits in the branch to skip before taking the count. 47 | # 48 | # @return [Array] the array of commits. 49 | def commits(branch='master', max_count=10, skip=0) 50 | raise Ginatra::InvalidRef unless branch_exists?(branch) 51 | 52 | walker = Rugged::Walker.new(@repo) 53 | walker.sorting(Rugged::SORT_TOPO) 54 | walker.push(@repo.ref("refs/heads/#{branch}").target) 55 | 56 | commits = walker.collect {|commit| commit } 57 | commits[skip, max_count] 58 | end 59 | 60 | # Returns list of branches sorted by name alphabetically 61 | def branches 62 | @repo.branches.each(:local).sort_by {|b| b.name } 63 | end 64 | 65 | # Returns list of branches containing the commit 66 | def branches_with(commit) 67 | b = [] 68 | branches.each do |branch| 69 | walker = Rugged::Walker.new(@repo) 70 | walker.sorting(Rugged::SORT_TOPO) 71 | walker.push(@repo.ref("refs/heads/#{branch.name}").target) 72 | walker.collect { |c| b << branch if c.oid == commit } 73 | end 74 | b 75 | end 76 | 77 | # Checks existence of branch by name 78 | def branch_exists?(branch_name) 79 | @repo.branches.exists?(branch_name) 80 | end 81 | 82 | # Find blob by oid 83 | def find_blob(oid) 84 | Rugged::Blob.new @repo, oid 85 | end 86 | 87 | # Find tree by tree oid or branch name 88 | def find_tree(oid) 89 | if branch_exists?(oid) 90 | last_commit_sha = @repo.ref("refs/heads/#{oid}").target.oid 91 | lookup(last_commit_sha).tree 92 | else 93 | lookup(oid) 94 | end 95 | end 96 | 97 | # Returns Rugged::Repository instance 98 | def to_rugged 99 | @repo 100 | end 101 | 102 | # Catch all 103 | # 104 | # @todo update respond_to? method 105 | def method_missing(sym, *args, &block) 106 | if @repo.respond_to?(sym) 107 | @repo.send(sym, *args, &block) 108 | else 109 | super 110 | end 111 | end 112 | 113 | # to correspond to the #method_missing definition 114 | def respond_to?(sym) 115 | @repo.respond_to?(sym) || super 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/ginatra/repo_list.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | 3 | module Ginatra 4 | # A singleton class that lets us make and use a constantly updating 5 | # list of repositories. 6 | class RepoList 7 | include Logger 8 | include Singleton 9 | attr_accessor :list 10 | 11 | # This creates the list, then does the first refresh to 12 | # populate it. 13 | # 14 | # It returns what refresh returns. 15 | def initialize 16 | self.list = [] 17 | self.refresh 18 | end 19 | 20 | # The preferred way to access the list publicly. 21 | # 22 | # @return [Array] a list of ginatra repos. 23 | def self.list 24 | self.instance.refresh 25 | self.instance.list 26 | end 27 | 28 | # searches through the configured directory globs to find all the repositories 29 | # and adds them if they're not already there. 30 | def refresh 31 | list.clear 32 | 33 | Ginatra.load_config["git_dirs"].map do |git_dir| 34 | if Dir.exist?(git_dir.chop) 35 | dirs = Dir.glob(git_dir).sort 36 | else 37 | dir = File.expand_path("../../../#{git_dir}", __FILE__) 38 | dirs = Dir.glob(dir).sort 39 | end 40 | 41 | dirs = dirs.select {|f| File.directory? f } 42 | dirs.each {|d| add(d) } 43 | end 44 | 45 | list 46 | end 47 | 48 | # adds a Repo corresponding to the path it found a git repo at in the configured 49 | # globs. Checks to see that it's not there first 50 | # 51 | # @param [String] path the path of the git repo 52 | # @param [String] param the param of the repo if it differs, 53 | # for looking to see if it's already on the list 54 | def add(path, param=File.split(path).last) 55 | unless self.has_repo?(param) 56 | begin 57 | list << Repo.new(path) 58 | rescue Rugged::RepositoryError 59 | logger.warn "SKIPPING '#{path}' - not a git repository" 60 | end 61 | end 62 | list 63 | end 64 | 65 | # checks to see if the list contains a repo with a param 66 | # matching the one passed in. 67 | # 68 | # @param [String] local_param param to check. 69 | # 70 | # @return [true, false] 71 | def has_repo?(local_param) 72 | !list.find { |r| r.param == local_param }.nil? 73 | end 74 | 75 | # quick way to look up if there is a repo with a given param in the list. 76 | # If not, it refreshes the list and tries again. 77 | # 78 | # @param [String] local_param the param to lookup 79 | # 80 | # @return [Ginatra::Repo] the repository corresponding to that param. 81 | def find(local_param) 82 | if repo = list.find { |r| r.param == local_param } 83 | repo 84 | else 85 | refresh 86 | repo = list.find { |r| r.param == local_param } 87 | raise Ginatra::RepoNotFound if repo.nil? 88 | repo 89 | end 90 | end 91 | 92 | # This just brings up the find method to the class scope. 93 | # 94 | # @see Ginatra::RepoList#find 95 | def self.find(local_param) 96 | self.instance.find(local_param) 97 | end 98 | 99 | # allows missing methods to cascade to the instance, 100 | def self.method_missing(sym, *args, &block) 101 | instance.send(sym, *args, &block) 102 | end 103 | 104 | # updated to correspond to the method_missing definition 105 | def self.respond_to?(sym) 106 | instance.respond_to?(sym) || super 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/ginatra/repo_stats.rb: -------------------------------------------------------------------------------- 1 | module Ginatra 2 | class RepoStats 3 | # @param [Ginatra::Repo] repo Ginatra::Repo instance 4 | # @param [String] ref Branch or tag name of repository 5 | # @return [Ginatra::RepoStats] 6 | def initialize(repo, ref) 7 | @repo = repo 8 | @ref = repo.branch_exists?(ref) ? repo.ref("refs/heads/#{ref}") : repo.ref("refs/tags/#{ref}") 9 | end 10 | 11 | # Contributors to repository 12 | # 13 | # @return [Array] Information about contributors sorted by commits count 14 | def contributors 15 | contributors = {} 16 | walker = Rugged::Walker.new(@repo.to_rugged) 17 | walker.push(@ref.target) 18 | 19 | walker.each do |commit| 20 | author = commit.author 21 | email = author[:email] 22 | 23 | if contributors[email] 24 | contributors[email] = { 25 | author: author[:name], 26 | commits_count: contributors[email][:commits_count] + 1 27 | } 28 | else 29 | contributors[email] = { 30 | author: author[:name], 31 | commits_count: 1 32 | } 33 | end 34 | end 35 | 36 | contributors.sort_by {|c| c.last[:commits_count] }.reverse 37 | end 38 | 39 | # Detect common OSS licenses 40 | # 41 | # @return [String] License name 42 | def license 43 | last_commit = @ref.target 44 | license = @repo.blob_at(last_commit.oid, 'LICENSE') || @repo.blob_at(last_commit.oid, 'LICENSE.txt') 45 | 46 | if license.nil? 47 | 'N/A' 48 | else 49 | license_text = license.text 50 | 51 | case license_text 52 | when /Apache License/ 53 | 'Apache' 54 | when /GNU GENERAL PUBLIC LICENSE/ 55 | 'GPL' 56 | when /GNU LESSER GENERAL PUBLIC LICENSE/ 57 | 'LGPL' 58 | when /Permission is hereby granted, free of charge,/ 59 | 'MIT' 60 | when /Redistribution and use in source and binary forms/ 61 | 'BSD' 62 | else 63 | 'N/A' 64 | end 65 | end 66 | end 67 | 68 | # Repository created at time 69 | # 70 | # @return [Time] Date of first commit to repository 71 | def created_at 72 | walker = Rugged::Walker.new(@repo.to_rugged) 73 | walker.sorting(Rugged::SORT_TOPO) 74 | walker.push(@ref.target) 75 | commit = walker.to_a.last 76 | Time.at(commit.time) 77 | end 78 | 79 | # Commits count in defined branch 80 | # 81 | # @return [Integer] Commits count 82 | def commits_count 83 | walker = Rugged::Walker.new(@repo.to_rugged) 84 | walker.push(@ref.target) 85 | walker.count 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/ginatra/version.rb: -------------------------------------------------------------------------------- 1 | module Ginatra 2 | VERSION = "4.1.0" 3 | RELEASE_NAME = "Baku" 4 | end 5 | -------------------------------------------------------------------------------- /lib/git/webby.rb: -------------------------------------------------------------------------------- 1 | # Standard requirements 2 | require 'json' 3 | require 'yaml' 4 | 5 | # 3rd part requirements 6 | require 'sinatra/base' 7 | 8 | # Internal requirements 9 | require 'git/webby/extensions' 10 | 11 | # See Git::Webby for documentation. 12 | module Git 13 | 14 | # The main goal of the Git::Webby is implement the following useful 15 | # features. 16 | # 17 | # - Smart-HTTP, based on _git-http-backend_. 18 | # - Authentication flexible based on database or configuration file like .htpasswd. 19 | # - API to get information about repository. 20 | # 21 | # This class configure the needed variables used by application. See 22 | # Config::DEFAULTS for the values will be initialized by default. 23 | # 24 | # Basically, the +default+ attribute set the values that will be necessary 25 | # by all applications. 26 | # 27 | # The HTTP-Backend application is configured by +http_backend+ attribute 28 | # to set the Git RCP CLI. More details about this feature, see the 29 | # {git-http-backend official 30 | # page}[http://www.kernel.org/pub/software/scm/git/docs/git-http-backend.html] 31 | # 32 | # [*default*] 33 | # Default configuration. All attributes will be used by all modular 34 | # applications. 35 | # 36 | # *project_root* :: 37 | # Sets the root directory where repositories have been 38 | # placed. 39 | # *git_path* :: 40 | # Path to the git command line. 41 | # 42 | # [*http_backend*] 43 | # HTTP-Backend configuration. 44 | # 45 | # *authenticate* :: 46 | # Sets if authentication is required. 47 | # 48 | # *get_any_file* :: 49 | # Like +http.getanyfile+. 50 | # 51 | # *upload_pack* :: 52 | # Like +http.uploadpack+. 53 | # 54 | # *receive_pack* :: 55 | # Like +http.receivepack+. 56 | module Webby 57 | 58 | class ProjectHandler #:nodoc: 59 | 60 | # Path to git command 61 | attr_reader :path 62 | 63 | attr_reader :project_root 64 | 65 | attr_reader :repository 66 | 67 | def initialize(project_root, path = "/usr/bin/git") 68 | @repository = nil 69 | @path = check_path(File.expand_path(path)) 70 | @project_root = check_path(File.expand_path(project_root)) 71 | end 72 | 73 | def path_to(*args) 74 | File.join(@repository || @project_root, *(args.compact.map(&:to_s))) 75 | end 76 | 77 | def repository=(name) 78 | @repository = check_path(path_to(name)) 79 | end 80 | 81 | def cli(command, *args) 82 | %Q[#{@path} #{args.unshift(command.to_s.gsub("_","-")).compact.join(" ")}] 83 | end 84 | 85 | def run(command, *args) 86 | chdir{ %x[#{cli command, *args}] } 87 | end 88 | 89 | def read_file(*file) 90 | File.read(path_to(*file)) 91 | end 92 | 93 | def loose_object_path(*hash) 94 | path_to(:objects, *hash) 95 | end 96 | 97 | def pack_idx_path(pack) 98 | path_to(:objects, :pack, pack) 99 | end 100 | 101 | def info_packs_path 102 | path_to(:objects, :info, :packs) 103 | end 104 | 105 | private 106 | 107 | def repository_path(name) 108 | bare = name =~ /\w\.git/ ? name : %W[#{name} .git] 109 | check_path(path_to(*bare)) 110 | end 111 | 112 | def check_path(path) 113 | path && !path.empty? && File.ftype(path) && path 114 | end 115 | 116 | def chdir(&block) 117 | Dir.chdir(@repository || @project_root, &block) 118 | end 119 | 120 | def ftype 121 | { "120" => "l", "100" => "-", "040" => "d" } 122 | end 123 | end 124 | 125 | class Htpasswd #:nodoc: 126 | 127 | def initialize(file) 128 | require 'webrick/httpauth/htpasswd' 129 | @handler = WEBrick::HTTPAuth::Htpasswd.new(file) 130 | yield self if block_given? 131 | end 132 | 133 | def find(username) #:yield: password, salt 134 | password = @handler.get_passwd(nil, username, false) 135 | if block_given? 136 | yield password ? [password, password[0,2]] : [nil, nil] 137 | else 138 | password 139 | end 140 | end 141 | 142 | def authenticated?(username, password) 143 | self.find username do |crypted, salt| 144 | crypted && salt && crypted == password.crypt(salt) 145 | end 146 | end 147 | 148 | def create(username, password) 149 | @handler.set_passwd(nil, username, password) 150 | end 151 | alias update create 152 | 153 | def destroy(username) 154 | @handler.delete_passwd(nil, username) 155 | end 156 | 157 | def include?(username) 158 | users.include? username 159 | end 160 | 161 | def size 162 | users.size 163 | end 164 | 165 | def write! 166 | @handler.flush 167 | end 168 | 169 | private 170 | 171 | def users 172 | @handler.each{|username, password| username } 173 | end 174 | end 175 | 176 | module GitHelpers #:nodoc: 177 | 178 | def git 179 | @git ||= ProjectHandler.new(settings.project_root, settings.git_path) 180 | end 181 | 182 | def repository 183 | git.repository ||= (params[:repository] || params[:captures].first) 184 | git 185 | end 186 | 187 | def content_type_for_git(name, *suffixes) 188 | content_type("application/x-git-#{name}-#{suffixes.compact.join("-")}") 189 | end 190 | 191 | end 192 | 193 | module AuthenticationHelpers #:nodoc: 194 | 195 | def htpasswd 196 | @htpasswd ||= Htpasswd.new(git.path_to("htpasswd")) 197 | end 198 | 199 | def authentication 200 | @authentication ||= Rack::Auth::Basic::Request.new request.env 201 | end 202 | 203 | def authenticated? 204 | request.env["REMOTE_USER"] && request.env["git.webby.authenticated"] 205 | end 206 | 207 | def authenticate(username, password) 208 | checked = [ username, password ] == authentication.credentials 209 | validated = authentication.provided? && authentication.basic? 210 | granted = htpasswd.authenticated? username, password 211 | if checked and validated and granted 212 | request.env["git.webby.authenticated"] = true 213 | request.env["REMOTE_USER"] = authentication.username 214 | else 215 | nil 216 | end 217 | end 218 | 219 | def unauthorized!(realm = Git::Webby::info) 220 | headers "WWW-Authenticate" => %(Basic realm="#{realm}") 221 | throw :halt, [ 401, "Authorization Required" ] 222 | end 223 | 224 | def bad_request! 225 | throw :halt, [ 400, "Bad Request" ] 226 | end 227 | 228 | def authenticate! 229 | return if authenticated? 230 | unauthorized! unless authentication.provided? 231 | bad_request! unless authentication.basic? 232 | unauthorized! unless authenticate(*authentication.credentials) 233 | request.env["REMOTE_USER"] = authentication.username 234 | end 235 | 236 | def access_granted?(username, password) 237 | authenticated? || authenticate(username, password) 238 | end 239 | 240 | end # AuthenticationHelpers 241 | 242 | # Servers 243 | autoload :HttpBackend, "git/webby/http_backend" 244 | 245 | class << self 246 | 247 | def config 248 | @config ||= { 249 | :default => { 250 | :project_root => "/home/git", 251 | :git_path => "/usr/bin/git" 252 | }, 253 | :http_backend => { 254 | :authenticate => true, 255 | :get_any_file => true, 256 | :upload_pack => true, 257 | :receive_pack => false 258 | } 259 | }.to_struct 260 | end 261 | 262 | # Configure Git::Webby modules using keys. See Config for options. 263 | def configure(&block) 264 | yield config 265 | config 266 | end 267 | 268 | def load_config_file(file) 269 | YAML.load_file(file).to_struct.each_pair do |app, options| 270 | options.each_pair do |option, value| 271 | config[app][option] = value 272 | end 273 | end 274 | config 275 | rescue IndexError 276 | abort "configuration option not found" 277 | end 278 | 279 | end 280 | 281 | class Application < Sinatra::Base #:nodoc: 282 | 283 | set :project_root, lambda { Git::Webby.config.default.project_root } 284 | set :git_path, lambda { Git::Webby.config.default.git_path } 285 | 286 | mime_type :json, "application/json" 287 | 288 | end 289 | 290 | end 291 | 292 | end 293 | -------------------------------------------------------------------------------- /lib/git/webby/extensions.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # Convert to Struct including all values that are Hash class. 3 | def to_struct 4 | keys = self.keys.sort 5 | members = keys.map(&:to_sym) 6 | Struct.new(*members).new(*keys.map do |key| 7 | (self[key].kind_of? Hash) ? self[key].to_struct : self[key] 8 | end) unless self.empty? 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/git/webby/http_backend.rb: -------------------------------------------------------------------------------- 1 | module Git::Webby 2 | 3 | module HttpBackendHelpers #:nodoc: 4 | 5 | include GitHelpers 6 | 7 | def service_request? 8 | not params[:service].nil? 9 | end 10 | 11 | # select_service feature 12 | def service 13 | @service = params[:service] 14 | return false if @service.nil? 15 | return false if @service[0, 4] != "git-" 16 | @service = @service.gsub("git-", "") 17 | end 18 | 19 | # pkt_write feature 20 | def packet_write(line) 21 | (line.size + 4).to_s(16).rjust(4, "0") + line 22 | end 23 | 24 | # pkt_flush feature 25 | def packet_flush 26 | "0000" 27 | end 28 | 29 | # hdr_nocache feature 30 | def header_nocache 31 | headers "Expires" => "Fri, 01 Jan 1980 00:00:00 GMT", 32 | "Pragma" => "no-cache", 33 | "Cache-Control" => "no-cache, max-age=0, must-revalidate" 34 | end 35 | 36 | # hdr_cache_forever feature 37 | def header_cache_forever 38 | now = Time.now 39 | headers "Date" => now.to_s, 40 | "Expires" => (now + 31536000).to_s, 41 | "Cache-Control" => "public, max-age=31536000" 42 | end 43 | 44 | # select_getanyfile feature 45 | def read_any_file 46 | unless settings.get_any_file 47 | halt 403, "Unsupported service: getanyfile" 48 | end 49 | end 50 | 51 | # get_text_file feature 52 | def read_text_file(*file) 53 | read_any_file 54 | header_nocache 55 | content_type "text/plain" 56 | repository.read_file(*file) 57 | end 58 | 59 | # get_loose_object feature 60 | def send_loose_object(prefix, suffix) 61 | read_any_file 62 | header_cache_forever 63 | content_type_for_git :loose, :object 64 | send_file(repository.loose_object_path(prefix, suffix)) 65 | end 66 | 67 | # get_pack_file and get_idx_file 68 | def send_pack_idx_file(pack, idx = false) 69 | read_any_file 70 | header_cache_forever 71 | content_type_for_git :packed, :objects, (idx ? :toc : nil) 72 | send_file(repository.pack_idx_path(pack)) 73 | end 74 | 75 | def send_info_packs 76 | read_any_file 77 | header_nocache 78 | content_type "text/plain; charset=utf-8" 79 | send_file(repository.info_packs_path) 80 | end 81 | 82 | # run_service feature 83 | def run_advertisement(service) 84 | header_nocache 85 | content_type_for_git service, :advertisement 86 | response.body.clear 87 | response.body << packet_write("# service=git-#{service}\n") 88 | response.body << packet_flush 89 | response.body << repository.run(service, "--stateless-rpc --advertise-refs .") 90 | response.finish 91 | end 92 | 93 | def run_process(service) 94 | content_type_for_git service, :result 95 | input = request.body.read 96 | command = repository.cli(service, "--stateless-rpc #{git.repository}") 97 | # This source has extracted from Grack written by Scott Chacon. 98 | IO.popen(command, File::RDWR) do |pipe| 99 | pipe.write(input) 100 | while !pipe.eof? 101 | block = pipe.read(8192) # 8M at a time 102 | response.write block # steam it to the client 103 | end 104 | end # IO 105 | response.finish 106 | end 107 | 108 | end # HttpBackendHelpers 109 | 110 | # The Smart HTTP handler server. This is the main Web application which respond to following requests: 111 | # 112 | # /HEAD :: HEAD contents 113 | # /info/refs :: Text file that contains references. 114 | # /objects/info/* :: Text file that contains all list of packets, alternates or http-alternates. 115 | # /objects/*/* :: Git objects, packets or indexes. 116 | # /upload-pack :: Post an upload packets. 117 | # /receive-pack :: Post a receive packets. 118 | # 119 | # See ::configure for more details. 120 | class HttpBackend < Application 121 | 122 | set :authenticate, true 123 | set :get_any_file, true 124 | set :upload_pack, true 125 | set :receive_pack, false 126 | 127 | helpers HttpBackendHelpers 128 | 129 | before do 130 | authenticate! if settings.authenticate 131 | end 132 | 133 | # implements the get_text_file function 134 | get "/:repository/HEAD" do 135 | read_text_file("HEAD") 136 | end 137 | 138 | # implements the get_info_refs function 139 | get "/:repository/info/refs" do 140 | if service_request? # by URL query parameters 141 | run_advertisement service 142 | else 143 | read_text_file(:info, :refs) 144 | end 145 | end 146 | 147 | # implements the get_text_file and get_info_packs functions 148 | get %r{/(.*?)/objects/info/(packs|alternates|http-alternates)$} do |repository, file| 149 | if file == "packs" 150 | send_info_packs 151 | else 152 | read_text_file(:objects, :info, file) 153 | end 154 | end 155 | 156 | # implements the get_loose_object function 157 | get %r{/(.*?)/objects/([0-9a-f]{2})/([0-9a-f]{38})$} do |repository, prefix, suffix| 158 | send_loose_object(prefix, suffix) 159 | end 160 | 161 | # implements the get_pack_file and get_idx_file functions 162 | get %r{/(.*?)/objects/pack/(pack-[0-9a-f]{40}.(pack|idx))$} do |repository, pack, ext| 163 | send_pack_idx_file(pack, ext == "idx") 164 | end 165 | 166 | # implements the service_rpc function 167 | post "/:repository/:service" do 168 | run_process service 169 | end 170 | 171 | private 172 | 173 | helpers AuthenticationHelpers 174 | 175 | end # HttpBackendServer 176 | 177 | end # Git::Webby 178 | -------------------------------------------------------------------------------- /lib/sinatra/partials.rb: -------------------------------------------------------------------------------- 1 | # This code is borrowed from http://github.com/cschneid/irclogger/blob/master/lib/partials.rb 2 | module Sinatra::Partials 3 | def partial(template, *args) 4 | template_array = template.to_s.split('/') 5 | template = template_array[0..-2].join('/') + "/_#{template_array[-1]}" 6 | options = args.last.is_a?(Hash) ? args.pop : {} 7 | options.merge!(:layout => false) 8 | if collection = options.delete(:collection) then 9 | collection.inject([]) do |buffer, member| 10 | buffer << erb(:"#{template}", options.merge(:layout => 11 | false, :locals => {template_array[-1].to_sym => member})) 12 | end.join("\n") 13 | else 14 | erb(:"#{template}", options) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /public/css/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | *= require twbs 3 | *= require ./lib/highlight 4 | *= require custom 5 | */ 6 | -------------------------------------------------------------------------------- /public/css/custom.sass: -------------------------------------------------------------------------------- 1 | .header 2 | margin: 15px 0 -20px 0 3 | 4 | .filter 5 | margin-top: -10px 6 | padding-bottom: 10px 7 | 8 | .author-box 9 | padding-top: 10px 10 | 11 | #loader 12 | display: none 13 | padding-bottom: 20px 14 | 15 | pre 16 | white-space: pre 17 | 18 | .btn .caret 19 | margin-left: 3px 20 | 21 | .img-rounded 22 | border-radius: 4px 23 | 24 | .clone-hint 25 | font-style: italic 26 | 27 | .dropdown-hint 28 | padding: 3px 20px 29 | 30 | .highlight 31 | border-radius: 4px 32 | margin-bottom: 20px 33 | pre 34 | border: none 35 | background-color: #f8f8f8 36 | table 37 | td 38 | padding: 0 39 | .gutter 40 | border-right: 1px solid #c0c0c0 41 | 42 | .diff-files-list 43 | display: none 44 | 45 | .repo-list a 46 | color: #337ab7 47 | &:hover 48 | color: #23527c 49 | 50 | a:focus 51 | color: #23527c 52 | 53 | .nav-pills 54 | margin-bottom: 10px 55 | > li > a 56 | padding: 7px 15px 57 | 58 | #diff 59 | padding-top: 20px 60 | .added a 61 | color: #468847 62 | .changed a 63 | color: #3a87ad 64 | .deleted a 65 | color: #b94a48 66 | 67 | .icon 68 | height: 16px 69 | width: 16px 70 | 71 | .service-page 72 | width: 50% 73 | margin: 0 auto 74 | text-align: center 75 | padding-top: 10% 76 | 77 | .info-box 78 | padding: 20px 79 | background-color: #eee 80 | border-radius: 6px 81 | 82 | .source-wrapper 83 | overflow: auto 84 | overflow-x: auto 85 | overflow-y: hidden 86 | 87 | .blob-image 88 | max-width: 700px 89 | border: 1px solid #fff 90 | outline: 1px solid #ccc 91 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAKElEQVQYlWO8f/8+AxJQUFBA5jIx4AU0lWb8//8/Mv/Bgwd0s5uANADTSgitzUUmWwAAAABJRU5ErkJggg==') 92 | &:hover 93 | outline: 1px solid #0088cc 94 | 95 | .hidden-header 96 | display: none 97 | position: fixed 98 | top: 0 99 | left: 0 100 | width: 100% 101 | z-index: 10 102 | 103 | .repo-header 104 | background-color: #6f5499 105 | padding: 30px 0 106 | margin-bottom: 20px 107 | color: #fff 108 | a 109 | color: #fff 110 | h1 111 | margin-top: 0 112 | color: #fff 113 | p.lead 114 | color: #cdbfe3 115 | .stats-created-at 116 | text-align: center 117 | margin-top: 40px 118 | .stats 119 | ul 120 | float: right 121 | li 122 | padding: 0 20px 123 | text-align: center 124 | border-right: 1px solid #fff 125 | &:last-child 126 | border: none 127 | .number 128 | display: block 129 | font-weight: bold 130 | font-size: 22px 131 | -------------------------------------------------------------------------------- /public/css/lib/highlight.css: -------------------------------------------------------------------------------- 1 | .highlight table td { padding: 5px; } 2 | .highlight table pre { margin: 0; } 3 | .highlight .cm { 4 | color: #999988; 5 | font-style: italic; 6 | } 7 | .highlight .cp { 8 | color: #999999; 9 | font-weight: bold; 10 | } 11 | .highlight .c1 { 12 | color: #999988; 13 | font-style: italic; 14 | } 15 | .highlight .cs { 16 | color: #999999; 17 | font-weight: bold; 18 | font-style: italic; 19 | } 20 | .highlight .c, .highlight .cd { 21 | color: #999988; 22 | font-style: italic; 23 | } 24 | .highlight .err { 25 | color: #a61717; 26 | background-color: #e3d2d2; 27 | } 28 | .highlight .gd { 29 | color: #000000; 30 | background-color: #ffdddd; 31 | } 32 | .highlight .ge { 33 | color: #000000; 34 | font-style: italic; 35 | } 36 | .highlight .gr { 37 | color: #aa0000; 38 | } 39 | .highlight .gh { 40 | color: #999999; 41 | } 42 | .highlight .gi { 43 | color: #000000; 44 | background-color: #ddffdd; 45 | } 46 | .highlight .go { 47 | color: #888888; 48 | } 49 | .highlight .gp { 50 | color: #555555; 51 | } 52 | .highlight .gs { 53 | font-weight: bold; 54 | } 55 | .highlight .gu { 56 | color: #aaaaaa; 57 | } 58 | .highlight .gt { 59 | color: #aa0000; 60 | } 61 | .highlight .kc { 62 | color: #000000; 63 | font-weight: bold; 64 | } 65 | .highlight .kd { 66 | color: #000000; 67 | font-weight: bold; 68 | } 69 | .highlight .kn { 70 | color: #000000; 71 | font-weight: bold; 72 | } 73 | .highlight .kp { 74 | color: #000000; 75 | font-weight: bold; 76 | } 77 | .highlight .kr { 78 | color: #000000; 79 | font-weight: bold; 80 | } 81 | .highlight .kt { 82 | color: #445588; 83 | font-weight: bold; 84 | } 85 | .highlight .k, .highlight .kv { 86 | color: #000000; 87 | font-weight: bold; 88 | } 89 | .highlight .mf { 90 | color: #009999; 91 | } 92 | .highlight .mh { 93 | color: #009999; 94 | } 95 | .highlight .il { 96 | color: #009999; 97 | } 98 | .highlight .mi { 99 | color: #009999; 100 | } 101 | .highlight .mo { 102 | color: #009999; 103 | } 104 | .highlight .m, .highlight .mb, .highlight .mx { 105 | color: #009999; 106 | } 107 | .highlight .sb { 108 | color: #d14; 109 | } 110 | .highlight .sc { 111 | color: #d14; 112 | } 113 | .highlight .sd { 114 | color: #d14; 115 | } 116 | .highlight .s2 { 117 | color: #d14; 118 | } 119 | .highlight .se { 120 | color: #d14; 121 | } 122 | .highlight .sh { 123 | color: #d14; 124 | } 125 | .highlight .si { 126 | color: #d14; 127 | } 128 | .highlight .sx { 129 | color: #d14; 130 | } 131 | .highlight .sr { 132 | color: #009926; 133 | } 134 | .highlight .s1 { 135 | color: #d14; 136 | } 137 | .highlight .ss { 138 | color: #990073; 139 | } 140 | .highlight .s { 141 | color: #d14; 142 | } 143 | .highlight .na { 144 | color: #008080; 145 | } 146 | .highlight .bp { 147 | color: #999999; 148 | } 149 | .highlight .nb { 150 | color: #0086B3; 151 | } 152 | .highlight .nc { 153 | color: #445588; 154 | font-weight: bold; 155 | } 156 | .highlight .no { 157 | color: #008080; 158 | } 159 | .highlight .nd { 160 | color: #3c5d5d; 161 | font-weight: bold; 162 | } 163 | .highlight .ni { 164 | color: #800080; 165 | } 166 | .highlight .ne { 167 | color: #990000; 168 | font-weight: bold; 169 | } 170 | .highlight .nf { 171 | color: #990000; 172 | font-weight: bold; 173 | } 174 | .highlight .nl { 175 | color: #990000; 176 | font-weight: bold; 177 | } 178 | .highlight .nn { 179 | color: #555555; 180 | } 181 | .highlight .nt { 182 | color: #000080; 183 | } 184 | .highlight .vc { 185 | color: #008080; 186 | } 187 | .highlight .vg { 188 | color: #008080; 189 | } 190 | .highlight .vi { 191 | color: #008080; 192 | } 193 | .highlight .nv { 194 | color: #008080; 195 | } 196 | .highlight .ow { 197 | color: #000000; 198 | font-weight: bold; 199 | } 200 | .highlight .o { 201 | color: #000000; 202 | font-weight: bold; 203 | } 204 | .highlight .w { 205 | color: #bbbbbb; 206 | } 207 | .highlight { 208 | background-color: #f8f8f8; 209 | } 210 | -------------------------------------------------------------------------------- /public/css/twbs.sass: -------------------------------------------------------------------------------- 1 | @import 'bootstrap-sprockets' 2 | @import 'bootstrap' 3 | 4 | h1, h2 5 | font-weight: bold 6 | 7 | small 8 | font-weight: normal 9 | 10 | .navbar 11 | margin-bottom: 0 12 | 13 | .page-header 14 | margin-top: 0 15 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NARKOZ/ginatra/9fc29d8385d1b82ff40f5d487bf3f5dd6311a20a/public/favicon.ico -------------------------------------------------------------------------------- /public/img/asterisk.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/exclamation-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/hdd-o.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 12 | 13 | 14 | 18 | 22 | 23 | 24 | 28 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /public/img/mail-forward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/minus-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/img/plus-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/js/application.js: -------------------------------------------------------------------------------- 1 | //= require ./lib/jquery.min 2 | //= require ./lib/bootstrap.min 3 | //= require ./lib/jquery.pjax 4 | //= require ./lib/jquery.lazyload.min 5 | //= require custom 6 | -------------------------------------------------------------------------------- /public/js/custom.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $(window).scroll(function() { 3 | if ($(window).scrollTop() >= 220) { 4 | $('.hidden-header').show(); 5 | } else { 6 | $('.hidden-header').hide(); 7 | } 8 | }); 9 | 10 | $('[data-toggle="tooltip"]').tooltip(); 11 | 12 | $('.js-lazy').lazyload({ 13 | effect: 'fadeIn', 14 | threshold: 200 15 | }); 16 | 17 | $('#js-toggle-file-listing').click(function() { 18 | var text = $(this).text(); 19 | $(this).text(text == 'Show file listing' ? 'Hide file listing': 'Show file listing'); 20 | $('#js-file-listing').toggle(); 21 | }); 22 | 23 | function selectText() { 24 | var range; 25 | 26 | if (document.selection) { 27 | range = document.body.createTextRange(); 28 | range.moveToElementText(this); 29 | range.select(); 30 | } else if (window.getSelection) { 31 | range = document.createRange(); 32 | range.selectNode(this); 33 | window.getSelection().addRange(range); 34 | } 35 | } 36 | 37 | $('#js-clone-url').click(selectText); 38 | 39 | $('.js-nav').click(function() { 40 | location.href = $(this).data('href'); 41 | }); 42 | 43 | $('#pjax-container').pjax('#js-tree a, #js-tree-nav a').on('pjax:send', function() { 44 | $('#loader').show(); 45 | }).on('pjax:end', function() { 46 | $('#js-clone-url').click(selectText); 47 | }); 48 | 49 | // filter repositories 50 | $('.js-filter-query').on('keyup', function() { 51 | var regexp = new RegExp($(this).val(), 'i'), 52 | $repolist = $('.js-repolist a'); 53 | 54 | $repolist.hide(); 55 | $repolist.filter(function() { 56 | return regexp.test($(this).text()); 57 | }).show(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /public/js/lib/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap.js by @fat & @mdo 3 | * Copyright 2012 Twitter, Inc. 4 | * http://www.apache.org/licenses/LICENSE-2.0.txt 5 | */ 6 | !function(e){"use strict";e(function(){e.support.transition=function(){var e=function(){var e=document.createElement("bootstrap"),t={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"},n;for(n in t)if(e.style[n]!==undefined)return t[n]}();return e&&{end:e}}()})}(window.jQuery),!function(e){"use strict";var t='[data-dismiss="alert"]',n=function(n){e(n).on("click",t,this.close)};n.prototype.close=function(t){function s(){i.trigger("closed").remove()}var n=e(this),r=n.attr("data-target"),i;r||(r=n.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,"")),i=e(r),t&&t.preventDefault(),i.length||(i=n.hasClass("alert")?n:n.parent()),i.trigger(t=e.Event("close"));if(t.isDefaultPrevented())return;i.removeClass("in"),e.support.transition&&i.hasClass("fade")?i.on(e.support.transition.end,s):s()},e.fn.alert=function(t){return this.each(function(){var r=e(this),i=r.data("alert");i||r.data("alert",i=new n(this)),typeof t=="string"&&i[t].call(r)})},e.fn.alert.Constructor=n,e(document).on("click.alert.data-api",t,n.prototype.close)}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.button.defaults,n)};t.prototype.setState=function(e){var t="disabled",n=this.$element,r=n.data(),i=n.is("input")?"val":"html";e+="Text",r.resetText||n.data("resetText",n[i]()),n[i](r[e]||this.options[e]),setTimeout(function(){e=="loadingText"?n.addClass(t).attr(t,t):n.removeClass(t).removeAttr(t)},0)},t.prototype.toggle=function(){var e=this.$element.closest('[data-toggle="buttons-radio"]');e&&e.find(".active").removeClass("active"),this.$element.toggleClass("active")},e.fn.button=function(n){return this.each(function(){var r=e(this),i=r.data("button"),s=typeof n=="object"&&n;i||r.data("button",i=new t(this,s)),n=="toggle"?i.toggle():n&&i.setState(n)})},e.fn.button.defaults={loadingText:"loading..."},e.fn.button.Constructor=t,e(document).on("click.button.data-api","[data-toggle^=button]",function(t){var n=e(t.target);n.hasClass("btn")||(n=n.closest(".btn")),n.button("toggle")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=n,this.options.slide&&this.slide(this.options.slide),this.options.pause=="hover"&&this.$element.on("mouseenter",e.proxy(this.pause,this)).on("mouseleave",e.proxy(this.cycle,this))};t.prototype={cycle:function(t){return t||(this.paused=!1),this.options.interval&&!this.paused&&(this.interval=setInterval(e.proxy(this.next,this),this.options.interval)),this},to:function(t){var n=this.$element.find(".item.active"),r=n.parent().children(),i=r.index(n),s=this;if(t>r.length-1||t<0)return;return this.sliding?this.$element.one("slid",function(){s.to(t)}):i==t?this.pause().cycle():this.slide(t>i?"next":"prev",e(r[t]))},pause:function(t){return t||(this.paused=!0),this.$element.find(".next, .prev").length&&e.support.transition.end&&(this.$element.trigger(e.support.transition.end),this.cycle()),clearInterval(this.interval),this.interval=null,this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(t,n){var r=this.$element.find(".item.active"),i=n||r[t](),s=this.interval,o=t=="next"?"left":"right",u=t=="next"?"first":"last",a=this,f;this.sliding=!0,s&&this.pause(),i=i.length?i:this.$element.find(".item")[u](),f=e.Event("slide",{relatedTarget:i[0]});if(i.hasClass("active"))return;if(e.support.transition&&this.$element.hasClass("slide")){this.$element.trigger(f);if(f.isDefaultPrevented())return;i.addClass(t),i[0].offsetWidth,r.addClass(o),i.addClass(o),this.$element.one(e.support.transition.end,function(){i.removeClass([t,o].join(" ")).addClass("active"),r.removeClass(["active",o].join(" ")),a.sliding=!1,setTimeout(function(){a.$element.trigger("slid")},0)})}else{this.$element.trigger(f);if(f.isDefaultPrevented())return;r.removeClass("active"),i.addClass("active"),this.sliding=!1,this.$element.trigger("slid")}return s&&this.cycle(),this}},e.fn.carousel=function(n){return this.each(function(){var r=e(this),i=r.data("carousel"),s=e.extend({},e.fn.carousel.defaults,typeof n=="object"&&n),o=typeof n=="string"?n:s.slide;i||r.data("carousel",i=new t(this,s)),typeof n=="number"?i.to(n):o?i[o]():s.interval&&i.cycle()})},e.fn.carousel.defaults={interval:5e3,pause:"hover"},e.fn.carousel.Constructor=t,e(document).on("click.carousel.data-api","[data-slide]",function(t){var n=e(this),r,i=e(n.attr("data-target")||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,"")),s=!i.data("carousel")&&e.extend({},i.data(),n.data());i.carousel(s),t.preventDefault()})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.collapse.defaults,n),this.options.parent&&(this.$parent=e(this.options.parent)),this.options.toggle&&this.toggle()};t.prototype={constructor:t,dimension:function(){var e=this.$element.hasClass("width");return e?"width":"height"},show:function(){var t,n,r,i;if(this.transitioning)return;t=this.dimension(),n=e.camelCase(["scroll",t].join("-")),r=this.$parent&&this.$parent.find("> .accordion-group > .in");if(r&&r.length){i=r.data("collapse");if(i&&i.transitioning)return;r.collapse("hide"),i||r.data("collapse",null)}this.$element[t](0),this.transition("addClass",e.Event("show"),"shown"),e.support.transition&&this.$element[t](this.$element[0][n])},hide:function(){var t;if(this.transitioning)return;t=this.dimension(),this.reset(this.$element[t]()),this.transition("removeClass",e.Event("hide"),"hidden"),this.$element[t](0)},reset:function(e){var t=this.dimension();return this.$element.removeClass("collapse")[t](e||"auto")[0].offsetWidth,this.$element[e!==null?"addClass":"removeClass"]("collapse"),this},transition:function(t,n,r){var i=this,s=function(){n.type=="show"&&i.reset(),i.transitioning=0,i.$element.trigger(r)};this.$element.trigger(n);if(n.isDefaultPrevented())return;this.transitioning=1,this.$element[t]("in"),e.support.transition&&this.$element.hasClass("collapse")?this.$element.one(e.support.transition.end,s):s()},toggle:function(){this[this.$element.hasClass("in")?"hide":"show"]()}},e.fn.collapse=function(n){return this.each(function(){var r=e(this),i=r.data("collapse"),s=typeof n=="object"&&n;i||r.data("collapse",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.collapse.defaults={toggle:!0},e.fn.collapse.Constructor=t,e(document).on("click.collapse.data-api","[data-toggle=collapse]",function(t){var n=e(this),r,i=n.attr("data-target")||t.preventDefault()||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,""),s=e(i).data("collapse")?"toggle":n.data();n[e(i).hasClass("in")?"addClass":"removeClass"]("collapsed"),e(i).collapse(s)})}(window.jQuery),!function(e){"use strict";function r(){e(t).each(function(){i(e(this)).removeClass("open")})}function i(t){var n=t.attr("data-target"),r;return n||(n=t.attr("href"),n=n&&/#/.test(n)&&n.replace(/.*(?=#[^\s]*$)/,"")),r=e(n),r.length||(r=t.parent()),r}var t="[data-toggle=dropdown]",n=function(t){var n=e(t).on("click.dropdown.data-api",this.toggle);e("html").on("click.dropdown.data-api",function(){n.parent().removeClass("open")})};n.prototype={constructor:n,toggle:function(t){var n=e(this),s,o;if(n.is(".disabled, :disabled"))return;return s=i(n),o=s.hasClass("open"),r(),o||(s.toggleClass("open"),n.focus()),!1},keydown:function(t){var n,r,s,o,u,a;if(!/(38|40|27)/.test(t.keyCode))return;n=e(this),t.preventDefault(),t.stopPropagation();if(n.is(".disabled, :disabled"))return;o=i(n),u=o.hasClass("open");if(!u||u&&t.keyCode==27)return n.click();r=e("[role=menu] li:not(.divider) a",o);if(!r.length)return;a=r.index(r.filter(":focus")),t.keyCode==38&&a>0&&a--,t.keyCode==40&&a').appendTo(document.body),this.$backdrop.click(this.options.backdrop=="static"?e.proxy(this.$element[0].focus,this.$element[0]):e.proxy(this.hide,this)),i&&this.$backdrop[0].offsetWidth,this.$backdrop.addClass("in"),i?this.$backdrop.one(e.support.transition.end,t):t()}else!this.isShown&&this.$backdrop?(this.$backdrop.removeClass("in"),e.support.transition&&this.$element.hasClass("fade")?this.$backdrop.one(e.support.transition.end,e.proxy(this.removeBackdrop,this)):this.removeBackdrop()):t&&t()}},e.fn.modal=function(n){return this.each(function(){var r=e(this),i=r.data("modal"),s=e.extend({},e.fn.modal.defaults,r.data(),typeof n=="object"&&n);i||r.data("modal",i=new t(this,s)),typeof n=="string"?i[n]():s.show&&i.show()})},e.fn.modal.defaults={backdrop:!0,keyboard:!0,show:!0},e.fn.modal.Constructor=t,e(document).on("click.modal.data-api",'[data-toggle="modal"]',function(t){var n=e(this),r=n.attr("href"),i=e(n.attr("data-target")||r&&r.replace(/.*(?=#[^\s]+$)/,"")),s=i.data("modal")?"toggle":e.extend({remote:!/#/.test(r)&&r},i.data(),n.data());t.preventDefault(),i.modal(s).one("hide",function(){n.focus()})})}(window.jQuery),!function(e){"use strict";var t=function(e,t){this.init("tooltip",e,t)};t.prototype={constructor:t,init:function(t,n,r){var i,s;this.type=t,this.$element=e(n),this.options=this.getOptions(r),this.enabled=!0,this.options.trigger=="click"?this.$element.on("click."+this.type,this.options.selector,e.proxy(this.toggle,this)):this.options.trigger!="manual"&&(i=this.options.trigger=="hover"?"mouseenter":"focus",s=this.options.trigger=="hover"?"mouseleave":"blur",this.$element.on(i+"."+this.type,this.options.selector,e.proxy(this.enter,this)),this.$element.on(s+"."+this.type,this.options.selector,e.proxy(this.leave,this))),this.options.selector?this._options=e.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},getOptions:function(t){return t=e.extend({},e.fn[this.type].defaults,t,this.$element.data()),t.delay&&typeof t.delay=="number"&&(t.delay={show:t.delay,hide:t.delay}),t},enter:function(t){var n=e(t.currentTarget)[this.type](this._options).data(this.type);if(!n.options.delay||!n.options.delay.show)return n.show();clearTimeout(this.timeout),n.hoverState="in",this.timeout=setTimeout(function(){n.hoverState=="in"&&n.show()},n.options.delay.show)},leave:function(t){var n=e(t.currentTarget)[this.type](this._options).data(this.type);this.timeout&&clearTimeout(this.timeout);if(!n.options.delay||!n.options.delay.hide)return n.hide();n.hoverState="out",this.timeout=setTimeout(function(){n.hoverState=="out"&&n.hide()},n.options.delay.hide)},show:function(){var e,t,n,r,i,s,o;if(this.hasContent()&&this.enabled){e=this.tip(),this.setContent(),this.options.animation&&e.addClass("fade"),s=typeof this.options.placement=="function"?this.options.placement.call(this,e[0],this.$element[0]):this.options.placement,t=/in/.test(s),e.detach().css({top:0,left:0,display:"block"}).insertAfter(this.$element),n=this.getPosition(t),r=e[0].offsetWidth,i=e[0].offsetHeight;switch(t?s.split(" ")[1]:s){case"bottom":o={top:n.top+n.height,left:n.left+n.width/2-r/2};break;case"top":o={top:n.top-i,left:n.left+n.width/2-r/2};break;case"left":o={top:n.top+n.height/2-i/2,left:n.left-r};break;case"right":o={top:n.top+n.height/2-i/2,left:n.left+n.width}}e.offset(o).addClass(s).addClass("in")}},setContent:function(){var e=this.tip(),t=this.getTitle();e.find(".tooltip-inner")[this.options.html?"html":"text"](t),e.removeClass("fade in top bottom left right")},hide:function(){function r(){var t=setTimeout(function(){n.off(e.support.transition.end).detach()},500);n.one(e.support.transition.end,function(){clearTimeout(t),n.detach()})}var t=this,n=this.tip();return n.removeClass("in"),e.support.transition&&this.$tip.hasClass("fade")?r():n.detach(),this},fixTitle:function(){var e=this.$element;(e.attr("title")||typeof e.attr("data-original-title")!="string")&&e.attr("data-original-title",e.attr("title")||"").removeAttr("title")},hasContent:function(){return this.getTitle()},getPosition:function(t){return e.extend({},t?{top:0,left:0}:this.$element.offset(),{width:this.$element[0].offsetWidth,height:this.$element[0].offsetHeight})},getTitle:function(){var e,t=this.$element,n=this.options;return e=t.attr("data-original-title")||(typeof n.title=="function"?n.title.call(t[0]):n.title),e},tip:function(){return this.$tip=this.$tip||e(this.options.template)},validate:function(){this.$element[0].parentNode||(this.hide(),this.$element=null,this.options=null)},enable:function(){this.enabled=!0},disable:function(){this.enabled=!1},toggleEnabled:function(){this.enabled=!this.enabled},toggle:function(t){var n=e(t.currentTarget)[this.type](this._options).data(this.type);n[n.tip().hasClass("in")?"hide":"show"]()},destroy:function(){this.hide().$element.off("."+this.type).removeData(this.type)}},e.fn.tooltip=function(n){return this.each(function(){var r=e(this),i=r.data("tooltip"),s=typeof n=="object"&&n;i||r.data("tooltip",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.tooltip.Constructor=t,e.fn.tooltip.defaults={animation:!0,placement:"top",selector:!1,template:'

      ',trigger:"hover",title:"",delay:0,html:!1}}(window.jQuery),!function(e){"use strict";var t=function(e,t){this.init("popover",e,t)};t.prototype=e.extend({},e.fn.tooltip.Constructor.prototype,{constructor:t,setContent:function(){var e=this.tip(),t=this.getTitle(),n=this.getContent();e.find(".popover-title")[this.options.html?"html":"text"](t),e.find(".popover-content > *")[this.options.html?"html":"text"](n),e.removeClass("fade top bottom left right in")},hasContent:function(){return this.getTitle()||this.getContent()},getContent:function(){var e,t=this.$element,n=this.options;return e=t.attr("data-content")||(typeof n.content=="function"?n.content.call(t[0]):n.content),e},tip:function(){return this.$tip||(this.$tip=e(this.options.template)),this.$tip},destroy:function(){this.hide().$element.off("."+this.type).removeData(this.type)}}),e.fn.popover=function(n){return this.each(function(){var r=e(this),i=r.data("popover"),s=typeof n=="object"&&n;i||r.data("popover",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.popover.Constructor=t,e.fn.popover.defaults=e.extend({},e.fn.tooltip.defaults,{placement:"right",trigger:"click",content:"",template:'

      '})}(window.jQuery),!function(e){"use strict";function t(t,n){var r=e.proxy(this.process,this),i=e(t).is("body")?e(window):e(t),s;this.options=e.extend({},e.fn.scrollspy.defaults,n),this.$scrollElement=i.on("scroll.scroll-spy.data-api",r),this.selector=(this.options.target||(s=e(t).attr("href"))&&s.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.$body=e("body"),this.refresh(),this.process()}t.prototype={constructor:t,refresh:function(){var t=this,n;this.offsets=e([]),this.targets=e([]),n=this.$body.find(this.selector).map(function(){var t=e(this),n=t.data("target")||t.attr("href"),r=/^#\w/.test(n)&&e(n);return r&&r.length&&[[r.position().top,n]]||null}).sort(function(e,t){return e[0]-t[0]}).each(function(){t.offsets.push(this[0]),t.targets.push(this[1])})},process:function(){var e=this.$scrollElement.scrollTop()+this.options.offset,t=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,n=t-this.$scrollElement.height(),r=this.offsets,i=this.targets,s=this.activeTarget,o;if(e>=n)return s!=(o=i.last()[0])&&this.activate(o);for(o=r.length;o--;)s!=i[o]&&e>=r[o]&&(!r[o+1]||e<=r[o+1])&&this.activate(i[o])},activate:function(t){var n,r;this.activeTarget=t,e(this.selector).parent(".active").removeClass("active"),r=this.selector+'[data-target="'+t+'"],'+this.selector+'[href="'+t+'"]',n=e(r).parent("li").addClass("active"),n.parent(".dropdown-menu").length&&(n=n.closest("li.dropdown").addClass("active")),n.trigger("activate")}},e.fn.scrollspy=function(n){return this.each(function(){var r=e(this),i=r.data("scrollspy"),s=typeof n=="object"&&n;i||r.data("scrollspy",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.scrollspy.Constructor=t,e.fn.scrollspy.defaults={offset:10},e(window).on("load",function(){e('[data-spy="scroll"]').each(function(){var t=e(this);t.scrollspy(t.data())})})}(window.jQuery),!function(e){"use strict";var t=function(t){this.element=e(t)};t.prototype={constructor:t,show:function(){var t=this.element,n=t.closest("ul:not(.dropdown-menu)"),r=t.attr("data-target"),i,s,o;r||(r=t.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,""));if(t.parent("li").hasClass("active"))return;i=n.find(".active:last a")[0],o=e.Event("show",{relatedTarget:i}),t.trigger(o);if(o.isDefaultPrevented())return;s=e(r),this.activate(t.parent("li"),n),this.activate(s,s.parent(),function(){t.trigger({type:"shown",relatedTarget:i})})},activate:function(t,n,r){function o(){i.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),t.addClass("active"),s?(t[0].offsetWidth,t.addClass("in")):t.removeClass("fade"),t.parent(".dropdown-menu")&&t.closest("li.dropdown").addClass("active"),r&&r()}var i=n.find("> .active"),s=r&&e.support.transition&&i.hasClass("fade");s?i.one(e.support.transition.end,o):o(),i.removeClass("in")}},e.fn.tab=function(n){return this.each(function(){var r=e(this),i=r.data("tab");i||r.data("tab",i=new t(this)),typeof n=="string"&&i[n]()})},e.fn.tab.Constructor=t,e(document).on("click.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(t){t.preventDefault(),e(this).tab("show")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.typeahead.defaults,n),this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.highlighter=this.options.highlighter||this.highlighter,this.updater=this.options.updater||this.updater,this.$menu=e(this.options.menu).appendTo("body"),this.source=this.options.source,this.shown=!1,this.listen()};t.prototype={constructor:t,select:function(){var e=this.$menu.find(".active").attr("data-value");return this.$element.val(this.updater(e)).change(),this.hide()},updater:function(e){return e},show:function(){var t=e.extend({},this.$element.offset(),{height:this.$element[0].offsetHeight});return this.$menu.css({top:t.top+t.height,left:t.left}),this.$menu.show(),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},lookup:function(t){var n;return this.query=this.$element.val(),!this.query||this.query.length"+t+""})},render:function(t){var n=this;return t=e(t).map(function(t,r){return t=e(n.options.item).attr("data-value",r),t.find("a").html(n.highlighter(r)),t[0]}),t.first().addClass("active"),this.$menu.html(t),this},next:function(t){var n=this.$menu.find(".active").removeClass("active"),r=n.next();r.length||(r=e(this.$menu.find("li")[0])),r.addClass("active")},prev:function(e){var t=this.$menu.find(".active").removeClass("active"),n=t.prev();n.length||(n=this.$menu.find("li").last()),n.addClass("active")},listen:function(){this.$element.on("blur",e.proxy(this.blur,this)).on("keypress",e.proxy(this.keypress,this)).on("keyup",e.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",e.proxy(this.keydown,this)),this.$menu.on("click",e.proxy(this.click,this)).on("mouseenter","li",e.proxy(this.mouseenter,this))},eventSupported:function(e){var t=e in this.$element;return t||(this.$element.setAttribute(e,"return;"),t=typeof this.$element[e]=="function"),t},move:function(e){if(!this.shown)return;switch(e.keyCode){case 9:case 13:case 27:e.preventDefault();break;case 38:e.preventDefault(),this.prev();break;case 40:e.preventDefault(),this.next()}e.stopPropagation()},keydown:function(t){this.suppressKeyPressRepeat=!~e.inArray(t.keyCode,[40,38,9,13,27]),this.move(t)},keypress:function(e){if(this.suppressKeyPressRepeat)return;this.move(e)},keyup:function(e){switch(e.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide();break;default:this.lookup()}e.stopPropagation(),e.preventDefault()},blur:function(e){var t=this;setTimeout(function(){t.hide()},150)},click:function(e){e.stopPropagation(),e.preventDefault(),this.select()},mouseenter:function(t){this.$menu.find(".active").removeClass("active"),e(t.currentTarget).addClass("active")}},e.fn.typeahead=function(n){return this.each(function(){var r=e(this),i=r.data("typeahead"),s=typeof n=="object"&&n;i||r.data("typeahead",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.typeahead.defaults={source:[],items:8,menu:'',item:'
    • ',minLength:1},e.fn.typeahead.Constructor=t,e(document).on("focus.typeahead.data-api",'[data-provide="typeahead"]',function(t){var n=e(this);if(n.data("typeahead"))return;t.preventDefault(),n.typeahead(n.data())})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.options=e.extend({},e.fn.affix.defaults,n),this.$window=e(window).on("scroll.affix.data-api",e.proxy(this.checkPosition,this)).on("click.affix.data-api",e.proxy(function(){setTimeout(e.proxy(this.checkPosition,this),1)},this)),this.$element=e(t),this.checkPosition()};t.prototype.checkPosition=function(){if(!this.$element.is(":visible"))return;var t=e(document).height(),n=this.$window.scrollTop(),r=this.$element.offset(),i=this.options.offset,s=i.bottom,o=i.top,u="affix affix-top affix-bottom",a;typeof i!="object"&&(s=o=i),typeof o=="function"&&(o=i.top()),typeof s=="function"&&(s=i.bottom()),a=this.unpin!=null&&n+this.unpin<=r.top?!1:s!=null&&r.top+this.$element.height()>=t-s?"bottom":o!=null&&n<=o?"top":!1;if(this.affixed===a)return;this.affixed=a,this.unpin=a=="bottom"?r.top-n:null,this.$element.removeClass(u).addClass("affix"+(a?"-"+a:""))},e.fn.affix=function(n){return this.each(function(){var r=e(this),i=r.data("affix"),s=typeof n=="object"&&n;i||r.data("affix",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.affix.Constructor=t,e.fn.affix.defaults={offset:0},e(window).on("load",function(){e('[data-spy="affix"]').each(function(){var t=e(this),n=t.data();n.offset=n.offset||{},n.offsetBottom&&(n.offset.bottom=n.offsetBottom),n.offsetTop&&(n.offset.top=n.offsetTop),t.affix(n)})})}(window.jQuery); -------------------------------------------------------------------------------- /public/js/lib/jquery.lazyload.min.js: -------------------------------------------------------------------------------- 1 | /*! Lazy Load 1.9.3 - MIT license - Copyright 2010-2013 Mika Tuupola */ 2 | !function(a,b,c,d){var e=a(b);a.fn.lazyload=function(f){function g(){var b=0;i.each(function(){var c=a(this);if(!j.skip_invisible||c.is(":visible"))if(a.abovethetop(this,j)||a.leftofbegin(this,j));else if(a.belowthefold(this,j)||a.rightoffold(this,j)){if(++b>j.failure_limit)return!1}else c.trigger("appear"),b=0})}var h,i=this,j={threshold:0,failure_limit:0,event:"scroll",effect:"show",container:b,data_attribute:"original",skip_invisible:!0,appear:null,load:null,placeholder:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC"};return f&&(d!==f.failurelimit&&(f.failure_limit=f.failurelimit,delete f.failurelimit),d!==f.effectspeed&&(f.effect_speed=f.effectspeed,delete f.effectspeed),a.extend(j,f)),h=j.container===d||j.container===b?e:a(j.container),0===j.event.indexOf("scroll")&&h.bind(j.event,function(){return g()}),this.each(function(){var b=this,c=a(b);b.loaded=!1,(c.attr("src")===d||c.attr("src")===!1)&&c.is("img")&&c.attr("src",j.placeholder),c.one("appear",function(){if(!this.loaded){if(j.appear){var d=i.length;j.appear.call(b,d,j)}a("").bind("load",function(){var d=c.attr("data-"+j.data_attribute);c.hide(),c.is("img")?c.attr("src",d):c.css("background-image","url('"+d+"')"),c[j.effect](j.effect_speed),b.loaded=!0;var e=a.grep(i,function(a){return!a.loaded});if(i=a(e),j.load){var f=i.length;j.load.call(b,f,j)}}).attr("src",c.attr("data-"+j.data_attribute))}}),0!==j.event.indexOf("scroll")&&c.bind(j.event,function(){b.loaded||c.trigger("appear")})}),e.bind("resize",function(){g()}),/(?:iphone|ipod|ipad).*os 5/gi.test(navigator.appVersion)&&e.bind("pageshow",function(b){b.originalEvent&&b.originalEvent.persisted&&i.each(function(){a(this).trigger("appear")})}),a(c).ready(function(){g()}),this},a.belowthefold=function(c,f){var g;return g=f.container===d||f.container===b?(b.innerHeight?b.innerHeight:e.height())+e.scrollTop():a(f.container).offset().top+a(f.container).height(),g<=a(c).offset().top-f.threshold},a.rightoffold=function(c,f){var g;return g=f.container===d||f.container===b?e.width()+e.scrollLeft():a(f.container).offset().left+a(f.container).width(),g<=a(c).offset().left-f.threshold},a.abovethetop=function(c,f){var g;return g=f.container===d||f.container===b?e.scrollTop():a(f.container).offset().top,g>=a(c).offset().top+f.threshold+a(c).height()},a.leftofbegin=function(c,f){var g;return g=f.container===d||f.container===b?e.scrollLeft():a(f.container).offset().left,g>=a(c).offset().left+f.threshold+a(c).width()},a.inviewport=function(b,c){return!(a.rightoffold(b,c)||a.leftofbegin(b,c)||a.belowthefold(b,c)||a.abovethetop(b,c))},a.extend(a.expr[":"],{"below-the-fold":function(b){return a.belowthefold(b,{threshold:0})},"above-the-top":function(b){return!a.belowthefold(b,{threshold:0})},"right-of-screen":function(b){return a.rightoffold(b,{threshold:0})},"left-of-screen":function(b){return!a.rightoffold(b,{threshold:0})},"in-viewport":function(b){return a.inviewport(b,{threshold:0})},"above-the-fold":function(b){return!a.belowthefold(b,{threshold:0})},"right-of-fold":function(b){return a.rightoffold(b,{threshold:0})},"left-of-fold":function(b){return!a.rightoffold(b,{threshold:0})}})}(jQuery,window,document); -------------------------------------------------------------------------------- /public/js/lib/jquery.pjax.js: -------------------------------------------------------------------------------- 1 | // jquery.pjax.js 2 | // copyright chris wanstrath 3 | // https://github.com/defunkt/jquery-pjax 4 | 5 | (function($){ 6 | 7 | // When called on a container with a selector, fetches the href with 8 | // ajax into the container or with the data-pjax attribute on the link 9 | // itself. 10 | // 11 | // Tries to make sure the back button and ctrl+click work the way 12 | // you'd expect. 13 | // 14 | // Exported as $.fn.pjax 15 | // 16 | // Accepts a jQuery ajax options object that may include these 17 | // pjax specific options: 18 | // 19 | // 20 | // container - Where to stick the response body. Usually a String selector. 21 | // $(container).html(xhr.responseBody) 22 | // (default: current jquery context) 23 | // push - Whether to pushState the URL. Defaults to true (of course). 24 | // replace - Want to use replaceState instead? That's cool. 25 | // 26 | // For convenience the second parameter can be either the container or 27 | // the options object. 28 | // 29 | // Returns the jQuery object 30 | function fnPjax(selector, container, options) { 31 | var context = this 32 | return this.on('click.pjax', selector, function(event) { 33 | var opts = $.extend({}, optionsFor(container, options)) 34 | if (!opts.container) 35 | opts.container = $(this).attr('data-pjax') || context 36 | handleClick(event, opts) 37 | }) 38 | } 39 | 40 | // Public: pjax on click handler 41 | // 42 | // Exported as $.pjax.click. 43 | // 44 | // event - "click" jQuery.Event 45 | // options - pjax options 46 | // 47 | // Examples 48 | // 49 | // $(document).on('click', 'a', $.pjax.click) 50 | // // is the same as 51 | // $(document).pjax('a') 52 | // 53 | // $(document).on('click', 'a', function(event) { 54 | // var container = $(this).closest('[data-pjax-container]') 55 | // $.pjax.click(event, container) 56 | // }) 57 | // 58 | // Returns nothing. 59 | function handleClick(event, container, options) { 60 | options = optionsFor(container, options) 61 | 62 | var link = event.currentTarget 63 | 64 | if (link.tagName.toUpperCase() !== 'A') 65 | throw "$.fn.pjax or $.pjax.click requires an anchor element" 66 | 67 | // Middle click, cmd click, and ctrl click should open 68 | // links in a new tab as normal. 69 | if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) 70 | return 71 | 72 | // Ignore cross origin links 73 | if ( location.protocol !== link.protocol || location.host !== link.host ) 74 | return 75 | 76 | // Ignore anchors on the same page 77 | if (link.hash && link.href.replace(link.hash, '') === 78 | location.href.replace(location.hash, '')) 79 | return 80 | 81 | // Ignore empty anchor "foo.html#" 82 | if (link.href === location.href + '#') 83 | return 84 | 85 | var defaults = { 86 | url: link.href, 87 | container: $(link).attr('data-pjax'), 88 | target: link, 89 | fragment: null 90 | } 91 | 92 | pjax($.extend({}, defaults, options)) 93 | 94 | event.preventDefault() 95 | } 96 | 97 | // Public: pjax on form submit handler 98 | // 99 | // Exported as $.pjax.submit 100 | // 101 | // event - "click" jQuery.Event 102 | // options - pjax options 103 | // 104 | // Examples 105 | // 106 | // $(document).on('submit', 'form', function(event) { 107 | // var container = $(this).closest('[data-pjax-container]') 108 | // $.pjax.submit(event, container) 109 | // }) 110 | // 111 | // Returns nothing. 112 | function handleSubmit(event, container, options) { 113 | options = optionsFor(container, options) 114 | 115 | var form = event.currentTarget 116 | 117 | if (form.tagName.toUpperCase() !== 'FORM') 118 | throw "$.pjax.submit requires a form element" 119 | 120 | var defaults = { 121 | type: form.method, 122 | url: form.action, 123 | data: $(form).serializeArray(), 124 | container: $(form).attr('data-pjax'), 125 | target: form, 126 | fragment: null, 127 | timeout: 0 128 | } 129 | 130 | pjax($.extend({}, defaults, options)) 131 | 132 | event.preventDefault() 133 | } 134 | 135 | // Loads a URL with ajax, puts the response body inside a container, 136 | // then pushState()'s the loaded URL. 137 | // 138 | // Works just like $.ajax in that it accepts a jQuery ajax 139 | // settings object (with keys like url, type, data, etc). 140 | // 141 | // Accepts these extra keys: 142 | // 143 | // container - Where to stick the response body. 144 | // $(container).html(xhr.responseBody) 145 | // push - Whether to pushState the URL. Defaults to true (of course). 146 | // replace - Want to use replaceState instead? That's cool. 147 | // 148 | // Use it just like $.ajax: 149 | // 150 | // var xhr = $.pjax({ url: this.href, container: '#main' }) 151 | // console.log( xhr.readyState ) 152 | // 153 | // Returns whatever $.ajax returns. 154 | function pjax(options) { 155 | options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options) 156 | 157 | if ($.isFunction(options.url)) { 158 | options.url = options.url() 159 | } 160 | 161 | var target = options.target 162 | 163 | var hash = parseURL(options.url).hash 164 | 165 | var context = options.context = findContainerFor(options.container) 166 | 167 | // We want the browser to maintain two separate internal caches: one 168 | // for pjax'd partial page loads and one for normal page loads. 169 | // Without adding this secret parameter, some browsers will often 170 | // confuse the two. 171 | if (!options.data) options.data = {} 172 | options.data._pjax = context.selector 173 | 174 | function fire(type, args) { 175 | var event = $.Event(type, { relatedTarget: target }) 176 | context.trigger(event, args) 177 | return !event.isDefaultPrevented() 178 | } 179 | 180 | var timeoutTimer 181 | 182 | options.beforeSend = function(xhr, settings) { 183 | // No timeout for non-GET requests 184 | // Its not safe to request the resource again with a fallback method. 185 | if (settings.type !== 'GET') { 186 | settings.timeout = 0 187 | } 188 | 189 | if (settings.timeout > 0) { 190 | timeoutTimer = setTimeout(function() { 191 | if (fire('pjax:timeout', [xhr, options])) 192 | xhr.abort('timeout') 193 | }, settings.timeout) 194 | 195 | // Clear timeout setting so jquerys internal timeout isn't invoked 196 | settings.timeout = 0 197 | } 198 | 199 | xhr.setRequestHeader('X-PJAX', 'true') 200 | xhr.setRequestHeader('X-PJAX-Container', context.selector) 201 | 202 | var result 203 | 204 | if (!fire('pjax:beforeSend', [xhr, settings])) 205 | return false 206 | 207 | options.requestUrl = parseURL(settings.url).href 208 | } 209 | 210 | options.complete = function(xhr, textStatus) { 211 | if (timeoutTimer) 212 | clearTimeout(timeoutTimer) 213 | 214 | fire('pjax:complete', [xhr, textStatus, options]) 215 | 216 | fire('pjax:end', [xhr, options]) 217 | } 218 | 219 | options.error = function(xhr, textStatus, errorThrown) { 220 | var container = extractContainer("", xhr, options) 221 | 222 | var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options]) 223 | if (options.type == 'GET' && textStatus !== 'abort' && allowed) { 224 | locationReplace(container.url) 225 | } 226 | } 227 | 228 | options.success = function(data, status, xhr) { 229 | var container = extractContainer(data, xhr, options) 230 | 231 | if (!container.contents) { 232 | locationReplace(container.url) 233 | return 234 | } 235 | 236 | pjax.state = { 237 | id: options.id || uniqueId(), 238 | url: container.url, 239 | title: container.title, 240 | container: context.selector, 241 | fragment: options.fragment, 242 | timeout: options.timeout 243 | } 244 | 245 | if (options.push || options.replace) { 246 | window.history.replaceState(pjax.state, container.title, container.url) 247 | } 248 | 249 | if (container.title) document.title = container.title 250 | context.html(container.contents) 251 | 252 | // Scroll to top by default 253 | if (typeof options.scrollTo === 'number') 254 | $(window).scrollTop(options.scrollTo) 255 | 256 | // Google Analytics support 257 | if ( (options.replace || options.push) && window._gaq ) 258 | _gaq.push(['_trackPageview']) 259 | 260 | // If the URL has a hash in it, make sure the browser 261 | // knows to navigate to the hash. 262 | if ( hash !== '' ) { 263 | // Avoid using simple hash set here. Will add another history 264 | // entry. Replace the url with replaceState and scroll to target 265 | // by hand. 266 | // 267 | // window.location.hash = hash 268 | var url = parseURL(container.url) 269 | url.hash = hash 270 | 271 | pjax.state.url = url.href 272 | window.history.replaceState(pjax.state, container.title, url.href) 273 | 274 | var target = $(url.hash) 275 | if (target.length) $(window).scrollTop(target.offset().top) 276 | } 277 | 278 | fire('pjax:success', [data, status, xhr, options]) 279 | } 280 | 281 | 282 | // Initialize pjax.state for the initial page load. Assume we're 283 | // using the container and options of the link we're loading for the 284 | // back button to the initial page. This ensures good back button 285 | // behavior. 286 | if (!pjax.state) { 287 | pjax.state = { 288 | id: uniqueId(), 289 | url: window.location.href, 290 | title: document.title, 291 | container: context.selector, 292 | fragment: options.fragment, 293 | timeout: options.timeout 294 | } 295 | window.history.replaceState(pjax.state, document.title) 296 | } 297 | 298 | // Cancel the current request if we're already pjaxing 299 | var xhr = pjax.xhr 300 | if ( xhr && xhr.readyState < 4) { 301 | xhr.onreadystatechange = $.noop 302 | xhr.abort() 303 | } 304 | 305 | pjax.options = options 306 | var xhr = pjax.xhr = $.ajax(options) 307 | 308 | if (xhr.readyState > 0) { 309 | if (options.push && !options.replace) { 310 | // Cache current container element before replacing it 311 | cachePush(pjax.state.id, context.clone().contents()) 312 | 313 | window.history.pushState(null, "", stripPjaxParam(options.requestUrl)) 314 | } 315 | 316 | fire('pjax:start', [xhr, options]) 317 | fire('pjax:send', [xhr, options]) 318 | } 319 | 320 | return pjax.xhr 321 | } 322 | 323 | // Public: Reload current page with pjax. 324 | // 325 | // Returns whatever $.pjax returns. 326 | function pjaxReload(container, options) { 327 | var defaults = { 328 | url: window.location.href, 329 | push: false, 330 | replace: true, 331 | scrollTo: false 332 | } 333 | 334 | return pjax($.extend(defaults, optionsFor(container, options))) 335 | } 336 | 337 | // Internal: Hard replace current state with url. 338 | // 339 | // Work for around WebKit 340 | // https://bugs.webkit.org/show_bug.cgi?id=93506 341 | // 342 | // Returns nothing. 343 | function locationReplace(url) { 344 | window.history.replaceState(null, "", "#") 345 | window.location.replace(url) 346 | } 347 | 348 | // popstate handler takes care of the back and forward buttons 349 | // 350 | // You probably shouldn't use pjax on pages with other pushState 351 | // stuff yet. 352 | function onPjaxPopstate(event) { 353 | var state = event.state 354 | 355 | if (state && state.container) { 356 | var container = $(state.container) 357 | if (container.length) { 358 | var contents = cacheMapping[state.id] 359 | 360 | if (pjax.state) { 361 | // Since state ids always increase, we can deduce the history 362 | // direction from the previous state. 363 | var direction = pjax.state.id < state.id ? 'forward' : 'back' 364 | 365 | // Cache current container before replacement and inform the 366 | // cache which direction the history shifted. 367 | cachePop(direction, pjax.state.id, container.clone().contents()) 368 | } 369 | 370 | var popstateEvent = $.Event('pjax:popstate', { 371 | state: state, 372 | direction: direction 373 | }) 374 | container.trigger(popstateEvent) 375 | 376 | var options = { 377 | id: state.id, 378 | url: state.url, 379 | container: container, 380 | push: false, 381 | fragment: state.fragment, 382 | timeout: state.timeout, 383 | scrollTo: false 384 | } 385 | 386 | if (contents) { 387 | container.trigger('pjax:start', [null, options]) 388 | 389 | if (state.title) document.title = state.title 390 | container.html(contents) 391 | pjax.state = state 392 | 393 | container.trigger('pjax:end', [null, options]) 394 | } else { 395 | pjax(options) 396 | } 397 | 398 | // Force reflow/relayout before the browser tries to restore the 399 | // scroll position. 400 | container[0].offsetHeight 401 | } else { 402 | locationReplace(location.href) 403 | } 404 | } 405 | } 406 | 407 | // Fallback version of main pjax function for browsers that don't 408 | // support pushState. 409 | // 410 | // Returns nothing since it retriggers a hard form submission. 411 | function fallbackPjax(options) { 412 | var url = $.isFunction(options.url) ? options.url() : options.url, 413 | method = options.type ? options.type.toUpperCase() : 'GET' 414 | 415 | var form = $('
      ', { 416 | method: method === 'GET' ? 'GET' : 'POST', 417 | action: url, 418 | style: 'display:none' 419 | }) 420 | 421 | if (method !== 'GET' && method !== 'POST') { 422 | form.append($('', { 423 | type: 'hidden', 424 | name: '_method', 425 | value: method.toLowerCase() 426 | })) 427 | } 428 | 429 | var data = options.data 430 | if (typeof data === 'string') { 431 | $.each(data.split('&'), function(index, value) { 432 | var pair = value.split('=') 433 | form.append($('', {type: 'hidden', name: pair[0], value: pair[1]})) 434 | }) 435 | } else if (typeof data === 'object') { 436 | for (key in data) 437 | form.append($('', {type: 'hidden', name: key, value: data[key]})) 438 | } 439 | 440 | $(document.body).append(form) 441 | form.submit() 442 | } 443 | 444 | // Internal: Generate unique id for state object. 445 | // 446 | // Use a timestamp instead of a counter since ids should still be 447 | // unique across page loads. 448 | // 449 | // Returns Number. 450 | function uniqueId() { 451 | return (new Date).getTime() 452 | } 453 | 454 | // Internal: Strips _pjax param from url 455 | // 456 | // url - String 457 | // 458 | // Returns String. 459 | function stripPjaxParam(url) { 460 | return url 461 | .replace(/\?_pjax=[^&]+&?/, '?') 462 | .replace(/_pjax=[^&]+&?/, '') 463 | .replace(/[\?&]$/, '') 464 | } 465 | 466 | // Internal: Parse URL components and returns a Locationish object. 467 | // 468 | // url - String URL 469 | // 470 | // Returns HTMLAnchorElement that acts like Location. 471 | function parseURL(url) { 472 | var a = document.createElement('a') 473 | a.href = url 474 | return a 475 | } 476 | 477 | // Internal: Build options Object for arguments. 478 | // 479 | // For convenience the first parameter can be either the container or 480 | // the options object. 481 | // 482 | // Examples 483 | // 484 | // optionsFor('#container') 485 | // // => {container: '#container'} 486 | // 487 | // optionsFor('#container', {push: true}) 488 | // // => {container: '#container', push: true} 489 | // 490 | // optionsFor({container: '#container', push: true}) 491 | // // => {container: '#container', push: true} 492 | // 493 | // Returns options Object. 494 | function optionsFor(container, options) { 495 | // Both container and options 496 | if ( container && options ) 497 | options.container = container 498 | 499 | // First argument is options Object 500 | else if ( $.isPlainObject(container) ) 501 | options = container 502 | 503 | // Only container 504 | else 505 | options = {container: container} 506 | 507 | // Find and validate container 508 | if (options.container) 509 | options.container = findContainerFor(options.container) 510 | 511 | return options 512 | } 513 | 514 | // Internal: Find container element for a variety of inputs. 515 | // 516 | // Because we can't persist elements using the history API, we must be 517 | // able to find a String selector that will consistently find the Element. 518 | // 519 | // container - A selector String, jQuery object, or DOM Element. 520 | // 521 | // Returns a jQuery object whose context is `document` and has a selector. 522 | function findContainerFor(container) { 523 | container = $(container) 524 | 525 | if ( !container.length ) { 526 | throw "no pjax container for " + container.selector 527 | } else if ( container.selector !== '' && container.context === document ) { 528 | return container 529 | } else if ( container.attr('id') ) { 530 | return $('#' + container.attr('id')) 531 | } else { 532 | throw "cant get selector for pjax container!" 533 | } 534 | } 535 | 536 | // Internal: Filter and find all elements matching the selector. 537 | // 538 | // Where $.fn.find only matches descendants, findAll will test all the 539 | // top level elements in the jQuery object as well. 540 | // 541 | // elems - jQuery object of Elements 542 | // selector - String selector to match 543 | // 544 | // Returns a jQuery object. 545 | function findAll(elems, selector) { 546 | return elems.filter(selector).add(elems.find(selector)); 547 | } 548 | 549 | // Internal: Extracts container and metadata from response. 550 | // 551 | // 1. Extracts X-PJAX-URL header if set 552 | // 2. Extracts inline tags 553 | // 3. Builds response Element and extracts fragment if set 554 | // 555 | // data - String response data 556 | // xhr - XHR response 557 | // options - pjax options Object 558 | // 559 | // Returns an Object with url, title, and contents keys. 560 | function extractContainer(data, xhr, options) { 561 | var obj = {} 562 | 563 | // Prefer X-PJAX-URL header if it was set, otherwise fallback to 564 | // using the original requested url. 565 | obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl) 566 | 567 | // Attempt to parse response html into elements 568 | if (/<html/i.test(data)) { 569 | var $head = $(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0]) 570 | var $body = $(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]) 571 | } else { 572 | var $head = $body = $(data) 573 | } 574 | 575 | // If response data is empty, return fast 576 | if ($body.length === 0) 577 | return obj 578 | 579 | // If there's a <title> tag in the header, use it as 580 | // the page's title. 581 | obj.title = findAll($head, 'title').last().text() 582 | 583 | if (options.fragment) { 584 | // If they specified a fragment, look for it in the response 585 | // and pull it out. 586 | if (options.fragment === 'body') { 587 | var $fragment = $body 588 | } else { 589 | var $fragment = findAll($body, options.fragment).first() 590 | } 591 | 592 | if ($fragment.length) { 593 | obj.contents = $fragment.contents() 594 | 595 | // If there's no title, look for data-title and title attributes 596 | // on the fragment 597 | if (!obj.title) 598 | obj.title = $fragment.attr('title') || $fragment.data('title') 599 | } 600 | 601 | } else if (!/<html/i.test(data)) { 602 | obj.contents = $body 603 | } 604 | 605 | // Clean up any <title> tags 606 | if (obj.contents) { 607 | // Remove any parent title elements 608 | obj.contents = obj.contents.not('title') 609 | 610 | // Then scrub any titles from their descendents 611 | obj.contents.find('title').remove() 612 | } 613 | 614 | // Trim any whitespace off the title 615 | if (obj.title) obj.title = $.trim(obj.title) 616 | 617 | return obj 618 | } 619 | 620 | // Internal: History DOM caching class. 621 | var cacheMapping = {} 622 | var cacheForwardStack = [] 623 | var cacheBackStack = [] 624 | 625 | // Push previous state id and container contents into the history 626 | // cache. Should be called in conjunction with `pushState` to save the 627 | // previous container contents. 628 | // 629 | // id - State ID Number 630 | // value - DOM Element to cache 631 | // 632 | // Returns nothing. 633 | function cachePush(id, value) { 634 | cacheMapping[id] = value 635 | cacheBackStack.push(id) 636 | 637 | // Remove all entires in forward history stack after pushing 638 | // a new page. 639 | while (cacheForwardStack.length) 640 | delete cacheMapping[cacheForwardStack.shift()] 641 | 642 | // Trim back history stack to max cache length. 643 | while (cacheBackStack.length > pjax.defaults.maxCacheLength) 644 | delete cacheMapping[cacheBackStack.shift()] 645 | } 646 | 647 | // Shifts cache from directional history cache. Should be 648 | // called on `popstate` with the previous state id and container 649 | // contents. 650 | // 651 | // direction - "forward" or "back" String 652 | // id - State ID Number 653 | // value - DOM Element to cache 654 | // 655 | // Returns nothing. 656 | function cachePop(direction, id, value) { 657 | var pushStack, popStack 658 | cacheMapping[id] = value 659 | 660 | if (direction === 'forward') { 661 | pushStack = cacheBackStack 662 | popStack = cacheForwardStack 663 | } else { 664 | pushStack = cacheForwardStack 665 | popStack = cacheBackStack 666 | } 667 | 668 | pushStack.push(id) 669 | if (id = popStack.pop()) 670 | delete cacheMapping[id] 671 | } 672 | 673 | // Install pjax functions on $.pjax to enable pushState behavior. 674 | // 675 | // Does nothing if already enabled. 676 | // 677 | // Examples 678 | // 679 | // $.pjax.enable() 680 | // 681 | // Returns nothing. 682 | function enable() { 683 | $.fn.pjax = fnPjax 684 | $.pjax = pjax 685 | $.pjax.enable = $.noop 686 | $.pjax.disable = disable 687 | $.pjax.click = handleClick 688 | $.pjax.submit = handleSubmit 689 | $.pjax.reload = pjaxReload 690 | $.pjax.defaults = { 691 | timeout: 650, 692 | push: true, 693 | replace: false, 694 | type: 'GET', 695 | dataType: 'html', 696 | scrollTo: 0, 697 | maxCacheLength: 20 698 | } 699 | $(window).bind('popstate.pjax', onPjaxPopstate) 700 | } 701 | 702 | // Disable pushState behavior. 703 | // 704 | // This is the case when a browser doesn't support pushState. It is 705 | // sometimes useful to disable pushState for debugging on a modern 706 | // browser. 707 | // 708 | // Examples 709 | // 710 | // $.pjax.disable() 711 | // 712 | // Returns nothing. 713 | function disable() { 714 | $.fn.pjax = function() { return this } 715 | $.pjax = fallbackPjax 716 | $.pjax.enable = enable 717 | $.pjax.disable = $.noop 718 | $.pjax.click = $.noop 719 | $.pjax.submit = $.noop 720 | $.pjax.reload = function() { window.location.reload() } 721 | 722 | $(window).unbind('popstate.pjax', onPjaxPopstate) 723 | } 724 | 725 | 726 | // Add the state property to jQuery's event object so we can use it in 727 | // $(window).bind('popstate') 728 | if ( $.inArray('state', $.event.props) < 0 ) 729 | $.event.props.push('state') 730 | 731 | // Is pjax supported by this browser? 732 | $.support.pjax = 733 | window.history && window.history.pushState && window.history.replaceState && 734 | // pushState isn't reliable on iOS until 5. 735 | !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/) 736 | 737 | $.support.pjax ? enable() : disable() 738 | 739 | })(jQuery); 740 | -------------------------------------------------------------------------------- /repos/README.md: -------------------------------------------------------------------------------- 1 | To make Ginatra see repositories, put them into this directory. You can do this 2 | by several ways: 3 | 4 | 1. Clone repository here 5 | 6 | git clone git://github.com/NARKOZ/ginatra.git 7 | 8 | 2. Symlink repository into there 9 | 10 | ln -s /path/to/repo repos/ 11 | 12 | 3. Copy repository here 13 | 14 | cp -R /path/to/repo repos/ 15 | 16 | 4. Move repository here 17 | 18 | mv -R /path/to/repo repos/ 19 | 20 | Run `ginatra --help` for additional command line help and see `config.yml` file 21 | for available settings. 22 | -------------------------------------------------------------------------------- /spec/ginatra/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::Helpers do 4 | before { allow(Time).to receive(:now).and_return(Time.new(2012, 12, 25, 0, 0, 0, '+00:00')) } 5 | 6 | let(:repo) { Ginatra::RepoList.find('test') } 7 | let(:commit) { repo.commit('095955b') } 8 | 9 | describe "#secure_mail" do 10 | it "returns masked email" do 11 | expect(secure_mail('eggscellent@example.com')).to eq('eggs...@example.com') 12 | end 13 | end 14 | 15 | describe "#gravatar_image_tag" do 16 | context "when options passed" do 17 | it "returns a gravatar image tag with custom options" do 18 | expect( 19 | gravatar_image_tag('john@example.com', size: 100, alt: 'John', class: 'avatar') 20 | ).to eq("<img src='https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=100' alt='John' height='100' width='100' class='avatar'>") 21 | end 22 | end 23 | 24 | context "when options not passed" do 25 | it "returns a gravatar image tag with default options" do 26 | expect( 27 | gravatar_image_tag('john@example.com') 28 | ).to eq("<img src='https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=40' alt='john' height='40' width='40'>") 29 | end 30 | end 31 | end 32 | 33 | describe "#file_icon" do 34 | context "symbolic link" do 35 | it "returns icon share-alt" do 36 | expect(file_icon(40960)).to eq("<img src='/img/mail-forward.svg' alt='symbolic link' class='icon'>") 37 | end 38 | end 39 | 40 | context "executable file" do 41 | it "returns icon asterisk" do 42 | expect(file_icon(33261)).to eq("<img src='/img/asterisk.svg' alt='executable file' class='icon'>") 43 | end 44 | end 45 | 46 | context "non-executable file" do 47 | it "returns icon file" do 48 | expect(file_icon(33188)).to eq("<img src='/img/file.svg' alt='file' class='icon'>") 49 | end 50 | end 51 | end 52 | 53 | describe "#nicetime" do 54 | it "returns a time in nice format" do 55 | expect(nicetime(Time.now)).to eq('Dec 25, 2012 – 00:00') 56 | end 57 | end 58 | 59 | describe "#time_tag" do 60 | it "returns a time in nice format" do 61 | expect( 62 | time_tag(Time.now) 63 | ).to eq("<time datetime='2012-12-25T00:00:00+0000' title='2012-12-25 00:00:00'>December 25, 2012 00:00</time>") 64 | end 65 | end 66 | 67 | describe "#patch_link" do 68 | it "returns a link for a commit patch" do 69 | expect( 70 | patch_link(commit, 'test') 71 | ).to eq("<a href='/test/commit/095955b6402c30ef24520bafdb8a8687df0a98d3.patch'>Download Patch</a>") 72 | end 73 | end 74 | 75 | describe "#empty_description_hint_for" do 76 | it "returns a hint for a repo with empty description" do 77 | hint_text = "Edit `#{repo.path}description` file to set the repository description." 78 | expect(empty_description_hint_for(repo)).to eq("<img src='/img/exclamation-circle.svg' title='#{hint_text}' alt='hint' class='icon'>") 79 | end 80 | end 81 | 82 | describe "#atom_feed_url" do 83 | context "when ref name passed" do 84 | it "returns a link to repo reference atom feed" do 85 | expect(atom_feed_url('test', 'master')).to eq("/test/master.atom") 86 | end 87 | end 88 | 89 | context "when ref name not passed" do 90 | it "returns a link to repo atom feed" do 91 | expect(atom_feed_url('test')).to eq("/test.atom") 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/ginatra/logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::Logger do 4 | describe "#logger" do 5 | it "returns logger instance" do 6 | expect(Ginatra::Logger.logger).to be_a(Logger) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/ginatra/repo_list_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::RepoList do 4 | let(:repo) { Ginatra::RepoList.find('test') } 5 | let(:repo_list) { Ginatra::RepoList.list } 6 | 7 | it "is an array of 'Ginatra::Repo'" do 8 | repo_list.each do |repo| 9 | expect(repo).to be_an_instance_of(Ginatra::Repo) 10 | end 11 | end 12 | 13 | it "contains the test repo" do 14 | expect(repo_list).to include(repo) 15 | end 16 | 17 | it "has_repo? works for existing repo" do 18 | expect(Ginatra::RepoList.instance.has_repo?('test')).to be true 19 | end 20 | 21 | it "has_repo? works for non-existant repo" do 22 | expect(Ginatra::RepoList.instance.has_repo?('bad-test')).to be false 23 | end 24 | 25 | describe "New repos added to repo directory" do 26 | before(:each) do 27 | @new_repo_name = 'temp-new-repo' 28 | @repo_dir = File.join(current_path, '..', 'repos') 29 | 30 | FileUtils.cd(@repo_dir) do |repo_dir| 31 | FileUtils.mkdir_p(@new_repo_name) 32 | FileUtils.cd(@new_repo_name) do |new_repo_dir| 33 | `git init` 34 | end 35 | end 36 | end 37 | 38 | it "should detect new repo after refresh" do 39 | repo_list = Ginatra::RepoList.list # calling this should refresh the list 40 | expect(Ginatra::RepoList.instance.has_repo?(@new_repo_name)).to be true 41 | 42 | new_repo = Ginatra::RepoList.find(@new_repo_name) 43 | expect(repo_list).to include(new_repo) 44 | end 45 | 46 | it "should detect when a repo has been removed after refresh" do 47 | repo_list = Ginatra::RepoList.list # calling this should refresh the list 48 | expect(Ginatra::RepoList.instance.has_repo?(@new_repo_name)).to be true 49 | 50 | new_repo = Ginatra::RepoList.find(@new_repo_name) 51 | expect(repo_list).to include(new_repo) 52 | 53 | # remove the new repository from the file system 54 | FileUtils.rm_rf File.join(@repo_dir, @new_repo_name), secure: true 55 | 56 | repo_list = Ginatra::RepoList.list # refresh the repo list 57 | 58 | expect(Ginatra::RepoList.instance.has_repo?(@new_repo_name)).to be false 59 | expect(repo_list).to_not include(new_repo) 60 | end 61 | 62 | after(:each) do 63 | FileUtils.rm_rf File.join(@repo_dir, @new_repo_name), secure: true 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/ginatra/repo_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::Repo do 4 | let(:repo) { Ginatra::RepoList.find('test') } 5 | 6 | describe "repo" do 7 | it "returns name" do 8 | expect(repo.name).to eq("test") 9 | end 10 | 11 | it "returns param" do 12 | expect(repo.param).to eq("test") 13 | end 14 | 15 | it "returns description" do 16 | expect(repo.description).to eq("") 17 | end 18 | end 19 | 20 | describe "#commit" do 21 | it "returns commit by sha" do 22 | commit = repo.commit '095955b' 23 | expect(commit).to be_a_kind_of(Rugged::Commit) 24 | expect(commit.oid).to eq('095955b6402c30ef24520bafdb8a8687df0a98d3') 25 | end 26 | end 27 | 28 | describe "#commit_by_tag" do 29 | it "returns commit by tag" do 30 | commit = repo.commit_by_tag 'v0.0.3' 31 | expect(commit).to be_a_kind_of(Rugged::Commit) 32 | expect(commit.oid).to eq('0c386b293878fb5f69031a998d564ecb8c2fee4d') 33 | end 34 | end 35 | 36 | describe "#commits" do 37 | context "when branch exist" do 38 | it "returns an array of commits" do 39 | commits = repo.commits('master', 2) 40 | expect(commits).to be_a_kind_of(Array) 41 | expect(commits.size).to eq(2) 42 | expect(commits.first.oid).to eq('095955b6402c30ef24520bafdb8a8687df0a98d3') 43 | end 44 | end 45 | 46 | context "when branch not exist" do 47 | it "raises Ginatra::InvalidRef" do 48 | expect { repo.commits('404-branch') }.to raise_error(Ginatra::InvalidRef) 49 | end 50 | end 51 | end 52 | 53 | describe "#branches" do 54 | it "returns an array of branches" do 55 | branches = repo.branches 56 | expect(branches).to be_a_kind_of(Array) 57 | expect(branches.size).to eq(1) 58 | expect(branches.first.name).to eq('master') 59 | expect(branches.first.target.oid).to eq('095955b6402c30ef24520bafdb8a8687df0a98d3') 60 | end 61 | end 62 | 63 | describe "#branches_with" do 64 | it "returns an array of branches including commit" do 65 | branches = repo.branches_with('095955b6402c30ef24520bafdb8a8687df0a98d3') 66 | expect(branches).to be_a_kind_of(Array) 67 | expect(branches.size).to eq(1) 68 | expect(branches.first.name).to eq('master') 69 | end 70 | end 71 | 72 | describe "#branch_exists?" do 73 | it "checks existence of branch" do 74 | expect(repo.branch_exists?('master')).to be true 75 | expect(repo.branch_exists?('master-404')).to be false 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/ginatra/repo_stats_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::RepoStats do 4 | let(:repo) { Ginatra::RepoList.find('test') } 5 | let(:repo_stats) { Ginatra::RepoStats.new(repo, 'master') } 6 | 7 | it "#license" do 8 | expect(repo_stats.license).to eq('MIT') 9 | end 10 | 11 | it "#commits_count" do 12 | expect(repo_stats.commits_count).to eq(57) 13 | end 14 | 15 | it "#contributors" do 16 | contributors = repo_stats.contributors 17 | expect(contributors).to be_a_kind_of(Array) 18 | expect(contributors.size).to eq(2) 19 | expect(contributors.first).to eq(['atmos@atmos.org', { author: 'Corey Donohoe', commits_count: 55 }]) 20 | end 21 | 22 | it "#created_at" do 23 | created_at = repo_stats.created_at 24 | expect(created_at).to be_a_kind_of(Time) 25 | expect(created_at.to_s).to include('2009-03-04') 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/ginatra_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Ginatra::App do 4 | describe "main page" do 5 | it "returns http success" do 6 | get '/' 7 | expect(last_response.status).to eq(200) 8 | end 9 | end 10 | 11 | describe "repo commits atom feed" do 12 | it "returns http success" do 13 | get '/test.atom' 14 | expect(last_response.status).to eq(200) 15 | end 16 | 17 | it "returns application/xml" do 18 | get '/test.atom' 19 | expect(last_response.headers['Content-Type']).to match("application/xml.*") 20 | end 21 | end 22 | 23 | describe "repo page" do 24 | it "returns http success" do 25 | get '/test' 26 | expect(last_response.status).to eq(200) 27 | end 28 | end 29 | 30 | describe "repo stats page" do 31 | it "returns http success" do 32 | get '/test/stats/master' 33 | expect(last_response.status).to eq(200) 34 | end 35 | end 36 | 37 | describe "branch commits atom feed" do 38 | it "returns http success" do 39 | get '/test/master.atom' 40 | expect(last_response.status).to eq(200) 41 | end 42 | 43 | it "returns application/xml" do 44 | get '/test/master.atom' 45 | expect(last_response.headers['Content-Type']).to match("application/xml.*") 46 | end 47 | end 48 | 49 | describe "repo branch page" do 50 | it "returns http success" do 51 | get '/test/master' 52 | expect(last_response.status).to eq(200) 53 | end 54 | end 55 | 56 | describe "repo commit patch" do 57 | it "returns http success" do 58 | get "/test/commit/095955b.patch" 59 | expect(last_response.status).to eq(200) 60 | end 61 | 62 | it "returns text/plain" do 63 | get "/test/commit/095955b.patch" 64 | expect(last_response.headers['Content-Type']).to match("text/plain.*") 65 | end 66 | end 67 | 68 | describe "repo commit page" do 69 | it "returns http success" do 70 | get "/test/commit/095955b" 71 | expect(last_response.status).to eq(200) 72 | end 73 | end 74 | 75 | describe "repo tag page" do 76 | it "returns http success" do 77 | get "/test/tag/v0.0.3" 78 | expect(last_response.status).to eq(200) 79 | end 80 | end 81 | 82 | describe "repo tree page" do 83 | it "returns http success" do 84 | get "/test/tree/master" 85 | expect(last_response.status).to eq(200) 86 | end 87 | end 88 | 89 | describe "repo tree page with path" do 90 | it "returns http success" do 91 | get "/test/tree/master/examples" 92 | expect(last_response.status).to eq(200) 93 | end 94 | end 95 | 96 | describe "repo blob page with path" do 97 | it "returns http success" do 98 | get '/test/blob/master/Gemfile' 99 | expect(last_response.status).to eq(200) 100 | end 101 | end 102 | 103 | describe "repo blob raw page" do 104 | it "returns http success" do 105 | get '/test/raw/master/Gemfile' 106 | expect(last_response.status).to eq(200) 107 | end 108 | 109 | it "returns text/plain" do 110 | get '/test/raw/master/Gemfile' 111 | expect(last_response.headers['Content-Type']).to match("text/plain.*") 112 | end 113 | end 114 | 115 | describe "repo log page" do 116 | it "returns http success" do 117 | get '/test/master/page/1' 118 | expect(last_response.status).to eq(200) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | 3 | require 'rspec' 4 | require 'ginatra' 5 | require 'rack/test' 6 | 7 | def app 8 | Ginatra::App 9 | end 10 | 11 | def current_path 12 | File.expand_path File.dirname(__FILE__) 13 | end 14 | 15 | RSpec.configure do |config| 16 | config.include Rack::Test::Methods 17 | config.include Ginatra::Helpers 18 | end 19 | 20 | Ginatra.config.git_dirs << "./repos/*" unless Ginatra.config.git_dirs.include?('./repos/*') 21 | -------------------------------------------------------------------------------- /views/404.erb: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8"> 5 | <title><%= title 'Error 404' %> 6 | " rel="stylesheet"> 7 | 8 | 9 | 10 |
      11 |
      12 |

      404 Page not found

      13 |

      Sorry, this page is not available.

      14 | Back to home 15 |
      16 |
      17 | 18 | 19 | -------------------------------------------------------------------------------- /views/500.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= title 'Error 500' %> 6 | " rel="stylesheet"> 7 | 8 | 9 | 10 |
      11 |
      12 |

      500 Internal Server Error

      13 |

      Sorry, something went wrong.

      14 | Back to home 15 |
      16 |
      17 | 18 | 19 | -------------------------------------------------------------------------------- /views/_footer.erb: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /views/_header.erb: -------------------------------------------------------------------------------- 1 | 17 | 18 |
      19 |
      20 |
      21 |
      22 |

      23 | <%= empty_description_hint_for(repo) %> 24 | <%= repo.name %> 25 |

      26 |

      <%= repo.description.split("\n").first %>

      27 | 28 | <% if Ginatra.config.git_clone_enabled? %> 29 |
      30 | git clone <%= url repo.param %> 31 |
      32 | <% end %> 33 |
      34 |
      35 |
        36 |
      • 37 | 38 | <%= stats.commits_count %> 39 | 40 | commits 41 |
      • 42 |
      • 43 | 44 | <%= stats.contributors.size %> 45 | 46 | contributors 47 |
      • 48 |
      • 49 | 50 | <%= stats.license %> 51 | 52 | license 53 |
      • 54 |
      55 |

      Created at: <%= time_tag stats.created_at %>

      56 |
      57 |
      58 |
      59 |
      60 | -------------------------------------------------------------------------------- /views/_tree_nav.erb: -------------------------------------------------------------------------------- 1 | <% if @repo.branch_exists?(params[:tree]) %> 2 |
      3 |
      4 | 15 | 16 |
      17 | 20 | 31 |
      32 |
      33 |
      34 | <% end %> 35 | 36 | <% unless params[:splat].nil? or params[:splat].empty? %> 37 | <% tree_nav = params[:splat].first.split('/') %> 38 |
      39 |
      40 | 55 |
      56 |
      57 | <% end %> 58 | -------------------------------------------------------------------------------- /views/atom.erb: -------------------------------------------------------------------------------- 1 | <% 2 | base_url = url prefix_url("#{@repo.param}") 3 | 4 | if params[:ref] 5 | url = "#{base_url}/#{params[:ref]}" 6 | name = "#{@repo.name}: #{params[:ref]}" 7 | else 8 | url = base_url 9 | name = "#{@repo.name}: master" 10 | end 11 | %> 12 | 13 | <%= url %> 14 | Commits to <%= name %> 15 | <%= h(@repo.description) %> 16 | 17 | 18 | <%= @commits.first ? rfc_date(@commits.first.committer[:time]) : rfc_date(Time.now.utc) %> 19 | <% @commits.each do |commit| %> 20 | 21 | <%= "#{base_url}/commit/#{commit.oid[0..6]}" %> 22 | <%= "Commit #{commit.oid[0..6]} to #{@repo.name}" %> 23 | <%= h(simple_format commit.message) %> 24 | "/> 25 | <%= rfc_date(commit.committer[:time]) %> 26 | 27 | <%= commit.author[:name] %> 28 | <%= commit.author[:email] %> 29 | 30 | 31 | <% end %> 32 | 33 | -------------------------------------------------------------------------------- /views/blob.erb: -------------------------------------------------------------------------------- 1 | <% title @blob[:name], headline: @repo.name %> 2 | 3 | <%= partial :tree_nav %> 4 | 5 | <% raw_blob_url = "#{prefix_url(@repo.param)}/raw/#{params[:tree]}/#{params[:splat].first}" %> 6 | 7 |
      8 |
      9 |

      <%= file_icon @blob[:filemode] %> <%= @blob[:name] %>

      10 |

      Raw

      11 |
      12 |
      13 | 14 | <% blob = @repo.find_blob @blob[:oid] %> 15 | 16 | <% if blob.binary? %> 17 | <% if %(gif png jpg jpeg).include? File.extname(@blob[:name])[1..-1].downcase %> 18 | 19 | <%= @blob[:name] %> 20 | 21 | <% else %> 22 |

      Binary file

      23 |

      download Download

      24 | <% end %> 25 | <% else %> 26 |
      27 | <%= highlight_source blob.content, @blob[:name] %> 28 |
      29 | <% end %> 30 | -------------------------------------------------------------------------------- /views/commit.erb: -------------------------------------------------------------------------------- 1 | <% title @repo.name, headline: "Commit #{@commit.oid[0..6]}" %> 2 | 3 |
      4 |
      5 |
      6 | <%= simple_format h(@commit.message) %> 7 | 8 |
      9 | <%= gravatar_image_tag @commit.author[:email], class: 'img-rounded', alt: @commit.author[:name] %> 10 | <%= @commit.author[:name] %> 11 | authored at <%= time_tag @commit.author[:time] %> 12 | 13 | <% unless @commit.committer[:name] == @commit.author[:name] %> 14 |
      15 | <%= gravatar_image_tag @commit.committer[:email], class: 'img-rounded', alt: @commit.committer[:name] %> 16 | <%= @commit.committer[:name] %> 17 | committed at <%= time_tag @commit.committer[:time] %> 18 |
      19 | <% end %> 20 |
      21 | 22 |
      23 |

      24 | as seen in: 25 | <% @repo.branches_with(@commit.oid).each do |branch| %> 26 | 27 | <%= h branch.name %> 28 | 29 | <% end %> 30 |

      31 |
      32 |
      33 |
      34 |
      35 |

      commit:

      36 |

      tree:

      37 | <% @commit.parents.size.times do %> 38 |

      parent:

      39 | <% end %> 40 |
      41 |
      42 |

      43 | 44 | <%= @commit.oid %> 45 | 46 |

      47 | 48 |

      49 | 50 | <%= @commit.tree.oid %> 51 | 52 |

      53 | 54 | <% @commit.parents.each do |parent| %> 55 |

      56 | 57 | <%= parent.oid %> 58 | 59 |

      60 | <% end %> 61 | 62 |

      download <%= patch_link @commit, @repo.param %>

      63 |
      64 |
      65 |
      66 |
      67 |
      68 | 69 |
      70 | <% diff = @commit.parents.first.diff(@commit) %> 71 | 72 |

      73 | 74 | 75 |

      76 | 77 |
      78 | <%= file_listing diff %> 79 |
      80 | 81 | <% diff.patches.each_with_index do |patch, index| %> 82 |
      83 |
      84 |

      85 | file <%= patch.delta.new_file[:path] %> 86 |

      87 |

      88 | <% unless patch.delta.deleted? %> 89 | view file 90 | <% end %> 91 |

      92 |
      93 |
      94 | 95 | <% patch.hunks.each do |hunk| %> 96 | <%= highlight_diff hunk %> 97 | <% end %> 98 | <% end %> 99 |
      100 | -------------------------------------------------------------------------------- /views/empty_repo.erb: -------------------------------------------------------------------------------- 1 | <% title @repo.name, headline: 'Empty repository' %> 2 | <%= partial :header, locals: { repo: @repo } %> 3 | 4 |
      5 |
      6 | Repository is empty. 7 |
      8 |
      9 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | <% title 'Repositories' %> 2 | 3 | 12 | 13 | <% if @repositories.size > 10 %> 14 |
      15 |
      16 | 17 |
      18 |
      19 | <% end %> 20 | 21 | 31 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= title %> 6 | 7 | " rel="stylesheet"> 8 | <% if @repo %> 9 | " type="application/atom+xml" rel="alternate"> 10 | <% end %> 11 | 12 | 13 | 23 | 24 | <% if @repo %> 25 | <%= partial :header, locals: { repo: @repo, stats: repo_stats(@repo) } %> 26 | <% end %> 27 | 28 |
      29 | <%= yield %> 30 |
      31 | 32 | <%= partial :footer %> 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /views/log.erb: -------------------------------------------------------------------------------- 1 | <% title @repo.name, headline: 'Commits' %> 2 | 3 |
      4 |
      5 |
      6 |
      7 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | <% @commits.each do |commit| %> 29 | <% url = prefix_url("#{@repo.param}/commit/#{commit.oid}") %> 30 | 31 | 32 | 33 | 34 | 35 | <% end %> 36 | 37 |
      AuthorCommit messageDate
      <%= gravatar_image_tag commit.author[:email], class: 'img-rounded', alt: commit.author[:name], lazy: true %> <%= commit.author[:name] %><%= truncate(h(commit.message), { length: 140, separator: ' ' }) %><%= time_tag(commit.committer[:time]) %>
      38 | 39 |
        40 | <% if @previous_commits %> 41 | 44 | <% end %> 45 | 46 | <% if @next_commits %> 47 | 50 | <% end %> 51 |
      52 |
      53 | 54 |
      55 |
      56 |
      Branches (<%= @repo.branches.size %>)
      57 | 58 | 65 |
      66 | 67 |
      68 |
      Tags (<%= @repo.tags.count %>)
      69 | 70 |
        71 | <% if @repo.tags.any? %> 72 | <% @repo.tags.each_name do |tag| %> 73 | 74 | <%= h tag %> 75 | 76 | <% end %> 77 | <% else %> 78 |
      • No tags
      • 79 | <% end %> 80 |
      81 |
      82 |
      83 |
      84 |
      85 |
      86 | -------------------------------------------------------------------------------- /views/stats.erb: -------------------------------------------------------------------------------- 1 | <% title @repo.name, headline: 'Stats' %> 2 | 3 |
      4 |
      5 |
      6 |
      7 | 18 | 19 |
        20 |
      • 21 | License: 22 | <%= @stats.license %> 23 |
      • 24 |
      • 25 | Commits: 26 | <%= @stats.commits_count %> 27 |
      • 28 |
      • 29 | Created at: 30 | <%= time_tag @stats.created_at %> 31 |
      • 32 |
      33 | 34 |

      Contributors (<%= @stats.contributors.size %>)

      35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | <% @stats.contributors.each do |contributor| %> 46 | 47 | 51 | 52 | 53 | 54 | <% end %> 55 | 56 |
      EmailAuthorCommits
      48 | <%= gravatar_image_tag contributor.first, size: 20, alt: contributor.last[:author], lazy: true %> 49 | <%= secure_mail contributor.first %> 50 | <%= contributor.last[:author] %><%= contributor.last[:commits_count] %>
      57 |
      58 | 59 |
      60 |
      61 |
      Branches (<%= @repo.branches.size %>)
      62 | 63 | 70 |
      71 | 72 |
      73 |
      Tags (<%= @repo.tags.count %>)
      74 | 75 |
        76 | <% if @repo.tags.any? %> 77 | <% @repo.tags.each_name do |tag| %> 78 | 79 | <%= h tag %> 80 | 81 | <% end %> 82 | <% else %> 83 |
      • No tags
      • 84 | <% end %> 85 |
      86 |
      87 |
      88 |
      89 |
      90 |
      91 | -------------------------------------------------------------------------------- /views/tree.erb: -------------------------------------------------------------------------------- 1 | <% title "#{@repo.name} at #{@tree.oid[0..6]}" %> 2 | 3 |
      4 | <%= partial :tree_nav %> 5 | 6 |
      " alt="loading">
      7 | 8 |
      9 | <% @tree.each do |tree| %> 10 | <% next unless tree[:type] == :commit && tree[:filemode] == 57344 %> 11 | 12 | submodule <%= tree[:name] %> 13 | 14 | <% end %> 15 | 16 | <% @tree.each_tree do |tree| %> 17 | 18 | folder <%= tree[:name] %> 19 | 20 | <% end %> 21 | 22 | <% @tree.each_blob do |blob| %> 23 | 24 | <%= file_icon blob[:filemode] %> <%= blob[:name] %> 25 | 26 | <% end %> 27 |
      28 |
      29 | --------------------------------------------------------------------------------