Git and libgit2

A Streamlined Git Commit

Published 2024-12-12. Last modified 2024-12-14.
Time to read: 2 minutes.

This page is part of the git collection.

Ever have git push choke on a large file, for example, a file over 2 GB? Wouldn't it be nice for the names of overly large files to automagically be added to .gitignore instead of having to look up how git undo works again?

Free Yourself From the Fear of Accidental Commitment

I have maintained a series of streamlined commit scripts for CVS, SVN and git for 19 years. I removed the CVS and SVN support several years ago, so only git is supported now.

Originally written in bash, the current version is written in Ruby, and uses the rugged gem. The Ruby language binding to libgit2 that rugged provides allows for more advanced features than git porcelain commands can offer.

High-Level Functionality

The commit scripts perform the following:

  1. git add
  2. git commit
  3. git push

The Ruby version examines the file sizes about to be added to the commit, and ensures that large files are added to .gitignore instead of causing git push to fail. A message is displayed, so the user is informed when large files are ignored.

Ruby Version

This is the latest version, and is the only version with the ability to handle large files. Using this version means you no longer have to worry about getting tangled up in undoing commits that failed because they included a large file.

This version requires that Ruby is set up properly on your computer. If you do not want to set up Ruby, you can run the older bash version of commit instead, but that version does not include the large file handling feature.

Installation

  1. Follow the directions in Setting Up a Ruby Development Environment.
  2. Install the rugged gem.
  3. Install the other dependencies:
    Shell
    $ gem install activesupport
    $ gem install colorator
    $ gem install optparse
  4. Download the commit script to a directory on your PATH, for example, /usr/local/bin/, or the standard Ubuntu directory for user scripts ~/.local/bin/
  5. Make the commit script executable:
    Shell
    $ chmod a+x /usr/local/bin/commit

Help Message

Shell
$ commit -h
Runs git commit without prompting for a message.
Files larger than 2 GB are added to .gitignore instead of being committed.
Usage: commit [options] [file...]
  Where options are:
      -a "tag message"
      -m "commit message"
      -v 0 # Minimum verbosity
      -v 1 # Default verbosity
      -v 2 # Maximum verbosity
Examples:
  commit  # The default commit message is just a single dash (-)
  commit -v 0
  commit -m "This is a commit message"
  commit -v 0 -m "This is a commit message"
  commit -a 0.1.2 

Source Code

The source code for the Ruby version of commit follows. The Walk Through Git’s Dirty Files section of the Working With Git Repos Using Ruby's Rugged Gem article discusses how the code works.

#!/usr/bin/env ruby

require 'active_support'
require 'active_support/core_ext/numeric/conversions'
require 'colorator'
require 'optparse'
require 'rugged'

# Originally written in bash by Mike Slinn 2005-09-05
# Converted to Ruby and added MAX_SIZE 2024-12-11
# See https://docs.github.com/en/repositories/working-with-files/managing-large-files/configuring-git-large-file-storage
# See https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github
class GitCommit
  KB = 1024
  MB = KB * KB
  GB = KB * MB
  # File size limits for git commits depend on the type of your GitHub account:
  GITHUB_FREE             = 100 * MB
  GITHUB_PRO              = 2 * GB
  GITHUB_TEAM             = 4 * GB
  GITHUB_ENTERPRISE_CLOUD = 5 * GB

  GIT_LFS_ENABLED = false

  MAX_SIZE = GIT_LFS_ENABLED ? GITHUB_FREE : 100 * MB # Adjust this to suit your GitHub account

  ActiveSupport::NumberHelper.alias_method :to_human, :number_to_human_size

  def initialize(default_branch: 'master')
    @branch = default_branch
    @gitignore_dirty = false
    @nh = ActiveSupport::NumberHelper
    @commit_size = 0
    @repo = Rugged::Repository.new '.'
  end

  # Needs absolute path or the path relative to the current directory, not just the name of the directory
  def add_recursively(name)
    Dir.entries(name).each do |entry|
      path = "#{name}/#{entry}"
      if File.directory(entry)
        scan_directory path
      else
        file_add path
      end
    end
  end

  def discover_branch
    if @repo.branches.entries.empty?
      # puts "\nYour git repository is empty. Please add at least one file before committing.".red
      # exit 4
      run "git branch -M #{@branch}"
    else
      @branch = @repo.head.name.sub(/^refs\/heads\//, '')
    end
  end

  # @param filename [String] Must be a path relative to the git root
  def file_add(filename)
    file_size = File.exist?(filename) ? File.size(filename) : 0
    if large_file?(filename)
      msg = <<~MESSAGE
        Not adding '#{filename}' because the git file size limit is #{@nh.to_human MAX_SIZE},
        however the file is #{@nh.to_human file_size}.
        The file will be added to .gitignore.
      MESSAGE
      puts msg.yellow unless @options[:verbosity].zero?

      newline = needs_newline('.gitignore') ? "\n" : ''
      File.write('.gitignore', "#{newline}#{filename}\n", mode: 'a')
      @gitignore_dirty = true
    elsif filename == '.gitignore'
      @gitignore_dirty = true
    else
      commit_push("A portion of the files to be committed is being pushed now because they are large.") if @commit_size + file_size >= MAX_SIZE / 2.0
      puts "Adding #{filename}".green unless @options[:verbosity].zero?
      run "git add '#{escape filename}'", verbose: @options[:verbosity] >= 2
    end
    @change_count += 1
    @commit_size += file_size
  end

  # Handles single quotes in filename
  def escape(string)
    string.gsub "'", "\\\\'"
  end

  def git_project?
    run 'git rev-parse 2> /dev/null', verbose: false
  end

  def help(msg = nil)
    printf "Error: #{msg}\n\n".yellow unless msg.nil?
    msg = <<~HELP
      Runs git commit without prompting for a message.
      Files larger than #{@nh.to_human MAX_SIZE} are added to .gitignore instead of being committed.
      Usage: commit [options] [file...]
        Where options are:
            -a "tag message"
            -m "commit message"
            -v 0 # Minimum verbosity
            -v 1 # Default verbosity
            -v 2 # Maximum verbosity
      Examples:
        commit  # The default commit message is just a single dash (-)
        commit -v 0
        commit -m "This is a commit message"
        commit -v 0 -m "This is a commit message"
        commit -a 0.1.2
    HELP
    puts msg.yellow
    exit 1
  end

  # Assumes .gitignore is never large
  def large_file?(filename)
    File.size(filename) > MAX_SIZE if File.exist?(filename)
  end

  def large_files
    large = []
    @repo.status do |path, flags|
      puts "#{path} #{flags}" if @options[:verbosity].positive?
      if File(path).dir?
        scan_dir path
      elsif large_file?(filename)
        large << path
      end
    end
    large
  end

  def commit_push(msg = nil)
    puts msg.yellow if msg
    msg = @options[:message] if @options[:message]
    discover_branch
    run("git commit -m '#{msg}' 2>&1 | sed -e '/^X11/d' -e '/^Warning:/d'", verbose: false)
    # @repo.push 'origin', ['refs/heads/master'] # Needs a callback to handle authentication
    puts "Pushing to origin #{@branch}".green unless @options[:verbosity].zero?
    run("git push origin #{@branch} --tags 3>&1 1>&2 2>&3 | sed -e '/^X11/d' -e '/^Warning:/d'", verbose: false)
    @change_count = 0
    @commit_size = 0
  end

  def main
    @options = parse_options
    if git_project?
      process_tag # Exits if a tag was created
      recursive_add
      commit_push if @commit_size.positive?
    else
      puts "Error: '#{Dir.pwd}' is not a git project".red
      exit 3
    end
  end

  def needs_newline(filename)
    return false unless File.exist? filename

    file_contents = File.read filename
    file_contents.nil? || file_contents.empty? || !file_contents.end_with?("\n")
  end

  # Sets class variables @options
  def parse_options
    options = { message: '-', verbosity: 1 }
    OptionParser.new do |parser|
      parser.program_name = File.basename __FILE__
      @parser = parser

      parser.on('-m', '--message MESSAGE', 'Specify commit message')
      parser.on('-v', '--verbosity VERBOSITY', Integer, 'Verbosity (0..2)')

      parser.on_tail('-h', '--help', 'Show this message') do
        help
      end
    end.order!(into: options)
    help "Invalid verbosity value (#{options[:verbosity]})." if options[:verbosity].negative? || options[:verbosity] > 2
    options
  end

  def process_tag
    tag = @options[:tag]
    if tag
      run("git tag -a #{tag} -m 'v#{tag}'", verbose: false)
      run("git push origin --tags", verbose: false)
      exit
    end
  end

  # Exclude big files, git add all others
  def recursive_add
    @change_count = 0
    @repo.status do |path, flags|
      next if flags.include? :ignored

      if File.directory? path
        scan_directory path
      else
        file_add path
      end
    end
    if @gitignore_dirty
      puts "Changing .gitignore".green unless @options[:verbosity].zero?
      run "git add .gitignore", verbose: false
      @change_count += 1
    end
    if @change_count.zero?
      puts "No changes were detected to this git repository.".green if @options[:verbosity].positive?
      exit
    end
  end

  def run(command, verbose: true, do_not_execute: false)
    puts command if verbose
    `#{command}`.chomp unless do_not_execute
  end

  def scan_directory(path)
    Dir.children(path) do |name|
      child_path = "#{path}/#{name}"
      if File.directory? child_path
        scan_directory child_path
      else
        file_add child_path
      end
    end
  end
end

if __FILE__ == $PROGRAM_NAME
  git_commit = GitCommit.new
  git_commit.main
end

Sample Usage

Shell
$ commit
Adding my_new_file.txt
Not adding 'video.mp4' because the git file size limit is 2 GB,
however the file is 2.12 GB.
The file will be added to .gitignore.
Pushing to origin master

$ commit No changes were detected to this git repository.

Older Bash Version

The bash version installs more easily than the Ruby version, but it cannot prevent big files from causing git push to choke and fail.

Installation

  1. Download the bash script to a directory on your PATH, for example, /usr/local/bin/, the standard Ubuntu directory for user scripts ~/.local/bin/
  2. Make the script executable:
    Shell
    $ chmod a+x /usr/local/bin/commit

Help Message

Shell
$ commit -h
Runs git commit without prompting for a message.
Usage: commit [options] [file...]
  Where options are:
      -a "tag message"
      -d # enables debug mode
      -m "commit message"
Examples:
  commit # The default commit message is just a single dash (-)
  commit -m "This is a commit message"
  commit -a 0.1.2 

Source Code

#!/bin/bash

# Originally written 2005-09-05 by Mike Slinn for CVS, then SVN, then git.

function help {
   echo "Runs git commit without prompting for a message."
   echo "Usage: commit [options] [file...]"
   echo "   Where options are:"
   echo "      -a \"tag message\""
   echo "      -d # enables debug mode"
   echo "      -m \"commit message\""
   echo "Examples:"
   echo "  commit  # The default commit message is just a single dash (-)"
   echo "  commit -m \"This is a commit message\""
   echo "  commit -a 0.1.2"
   exit 1
}

function isGitProject {
  cd "$( git rev-parse --git-dir )/.." || exit 2
  [ -d .git ]
}


BRANCH="$(git rev-parse --abbrev-ref HEAD)"
MSG=""
while getopts "a:dhm:?" opt; do
   case $opt in
       a ) TAG="$OPTARG"
           git tag -a "$TAG" -m "v$TAG"
           git push origin --tags
           exit
           ;;
       d ) set -xv ;;
       m ) MSG="$OPTARG" ;;
       h ) help ;;
       \?) help ;;
   esac
done
shift "$((OPTIND-1))"


for o in "$@"; do
   if [ "$o" == "-m" ]; then unset MSG; fi
done

if isGitProject; then
  if [ "$@" ]; then
    git add -A "$@"
  else
    git add -A .
  fi
  shift
  if [ "$MSG" == "" ]; then MSG="-"; fi
  git commit -m "$MSG" "$@" 3>&1 1>&2 2>&3 | sed -e '/^X11/d'  | sed -e '/^Warning:/d'
  git push origin "$BRANCH" --tags 3>&1 1>&2 2>&3 | sed -e '/^X11/d'  | sed -e '/^Warning:/d'
else
  echo "Error: '$( pwd )' is not a git project"
fi

if [ -f 0 ]; then rm -f 0; fi
* indicates a required field.

Please select the following to receive Mike Slinn’s newsletter:

You can unsubscribe at any time by clicking the link in the footer of emails.

Mike Slinn uses Mailchimp as his marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp’s privacy practices.