I've hacked on this for personal use, mostly, but I figured if there could be interest in such a beast, this is probably one of the best places to find it. So, see attached BuGit, an issue tracking system which stores its database in Git to try and get "distributed operation for free". By pushing all the hard work to Git, BuGit is able to implement a distributed bug tracking system in a simple shell script (less than 20KB so far). It can of course do off-line operations, but it can also share bugs between unrelated databases (e.g. if you forward a bug report to someone else using this same system, you can keep the two bugs sync'd, even if they may get different bug-numbers on each side). Obviously, this is lacking in many respects. You can get a barely tolerable web-based UI (but only to browse bugs, not to manipulate them) with any Git front-end, but if you really need a web-based UI it'll take extra work. Stefan #!/bin/sh ### BuGit --- File-less distributed bug tracking system with Git # The design is based on the idea of trying to represent the bug-database # in such a way that Git's merge takes care of our own merge needs. IOW # Git's merge should only result in conflicts when there is a *real* conflict # that can only be resolved by hand (e.g. two concurrent changes to the title # of a bug). # # The general idea is as follows: # - Keep messages in the metadata (more specifically the commit log), so # they can't generate conflicts, they're auto-merged, and the ordering # automatically preserved. # - Most other data is kept in file *names* (i.e. the files themselves are # empty, to avoid merge conflicts). # In BuGit, bugs can be identified in 3 ways: # - ID: Bugs have a unique and immutable identification called "ID", usually # some kind of random hexadecimal number. This is the only stable and # unambiguous identification. # - NAME: Bugs have a "NAME", also known as their "title". This is # a human-readable text which is expected to describe the bug concisely. # This property can change over time, and several bugs can have # the same NAME, tho this should be unusual. # - NB: Bugs can have a number. This is not a property of the bug, tho, in # the sense that the same bug can be known under different numbers in # different databases. So a bug can have several different NBs, and # initially a bug has no NB at all (since allocation of a bug NB tends to # be a centralized operation). But in a given database, a given bug # has at most one NB. # If BuGit says it wants a "BUG", it means that you can give it any one of # those kinds of identifiers. # The database is layed out as follows: # - Every bug lives in its own branch named "bugs/ID". # The branch's commit messages hold the bug's messages. # The branch's files are as follows: # - "name": simple file holding the current NAME of this bug. # - "attachments/<timestamp> - <name>" are attachments. # - "followers/<email>" are empty files whose name indicates that <email> # would like to receive updates on this bug. # - "assigned-to/<email>" are empty files whose name indicate that <email> # is reponsible for this bug. # - "tags/<tag>" are empty files indicating that <tag> is applied to the bug. # Additionally to those bug branches, there is a "master" branch whose # commit message are unimportant and whose files are: # - "names/NAME/ID": empty file indicating that bug ID has name NAME. # This is a cache used to perform reverse lookups (from NAME to ID), # and it currently is not always kept up-to-date. FIXME! # - "numbers/NB": simple file holding the ID of bug number NB. # # By design, the only possible sources of conflicts when merging different # databases are: # - if different bugs have the same NB. # - if a given bug ID has different names. # - if a bug has two different attachments with the same name same timestamp # [ the timestamp should make this very unlikely, tho ]. ## Helper functions ########################################################### bugit_upcase () { echo "$@" | tr '[:lower:]' '[:upper:]' } bugit_make_optloop () { echo 'done=""' echo 'while [ $# -gt 0 ] && [ "" = "$done" ]; do' echo 'case $1 in' for arg in $(echo $1); do case $arg in *\=) argname=$(echo "$arg" | sed 's/=$//') echo "--${arg}*) ${argname}=\$(echo \$1 | sed 's/^[^=]*=//')" echo "shift ;;" echo "--${argname})" echo "[ \$# -gt 1 ] || invalid \"Missing arg for \$1 option\"" echo "${argname}=\$2; shift 2 ;;" ;; *) echo "--${arg}) ${arg}=true; shift ;;" ;; esac done echo '--) done=--; shift ;;' echo '--*) invalid "Invalid option $1" ;;' echo '*) done=-- ;;' echo 'esac' echo 'done' shift while [ $# -gt 0 ]; do arg=$1 case $arg in "...") [ $# = 1 ] || internal_error "... can only be last" rest=ok ;; "["*"]") argname=$(echo "$arg" | sed 's/[][]//g') echo "[ \$# = 0 ] || { $argname=\$1 ; shift; }" ;; *) echo "[ \$# -gt 0 ] ||" echo "invalid 'Missing argument $(bugit_upcase "$arg")'" echo "{ $arg=\$1 ; shift; }" ;; esac shift done [ "ok" = "$rest" ] || echo "[ \$# = 0 ] || invalid 'Unexpected extra args:' \"\$@\"" } invalid () { echo "$@"; echo source=$(which "$0") sed -ne 's|^bugit_cmd_\([^ ()]*\).*#|bugit \1|p' <"$source" exit 1 } user_error () { echo "$@" exit 1 } internal_error () { user_error "Internal error!" "$@"; } bugit_get_id () { bug="$1" [ ! "" = "$bug" ] || user_error "Empty bug identifer!" if git show-ref "bugs/$bug" >/dev/null; then id="$bug"; else bugit_checkout_master if [ -f "numbers/$bug" ]; then id=$(cat "numbers/$bug"); else name=$(bugit_to_filename "$bug") ids=$(ls "names/$name" 2>/dev/null) case $ids in "") user_error "No bug by that name" ;; *" "*) user_error "Ambiguous name: $ids" ;; *) id="$ids" ;; esac fi fi } bugit_in_master_p () { [ "ref: refs/heads/master" = "$(cat .git/HEAD)" ] } bugit_get_number () { bugit_in_master_p || internal_error "bugit_get_number while not in master!" set -- $(cd numbers 2>/dev/null && grep -l "$1" * 2>/dev/null) if [ "$#" = 0 ]; then return 1; else number=$1; fi } bugit_get_name () { id=$1 bugit_get_branch "$id" nomerge name=$(git cat-file blob "$branch:name") if bugit_in_master_p; then # Let's just double check that the "names" subdir is up-to-date filename=$(bugit_to_filename "$name") [ -f "names/$filename/$id" ] || { touch "names/$filename/$id" git add "names/$filename/$id" # FIXME: If bugit_get_name is called several times (as is the # case for "bugit list"), we'd want to combine all these commits # into a single one. git commit -m "Update name of $id" } fi } bugit_assert_clean_p () { [ "" = "$(git status --porcelain)" ] || user_error "Uncommitted changes!" } bugit_checkout_master () { bugit_in_master_p || { bugit_assert_clean_p git checkout master } } bugit_get_branch () { # Find the branch of a given bug-id id=$1 nomerge=$2 if git show-ref -q --verify "refs/heads/bugs/$id"; then branch="refs/heads/bugs/$id" else branches=$(git for-each-ref --format "%(refname)" \ "refs/remotes/*/bugs/$id") set -- $branches case $# in 0) user_error "No bug with id '$id'" ;; 1) branch=$1 ;; *) if [ "nomerge" = "$nomerge" ]; then branch=$1 else bugit_checkout_id "$id" branch="refs/heads/bugs/$id" fi ;; esac fi } bugit_merge () { bug=$1 branch=$2 git merge -m Merge "$branch" || user_error "Merge conflict in bug '$bug'" } bugit_checkout_id () { # Checkout the branch for bug ID. id=$1 bugit_assert_clean_p if git show-ref -q --verify "refs/heads/bugs/$id"; then git checkout "bugs/$id" else branches=$(git for-each-ref --format "%(refname)" \ "refs/remotes/*/bugs/$id") [ ! "" = "$branches" ] || user_error "No bug with id '$id'" set -- $branches first="$1"; shift git checkout -b "bugs/$id" "$first" for branch; do bugit_merge "$id" "$branch" done fi } bugit_author () { echo "$(git config --get user.name) <$(git config --get user.email)>" } bugit_to_branchname () { echo "$@" | tr ' ' '_' } bugit_to_filename () { echo "$@" | tr '/' '_' } bugit_to_bugname () { tr '_' '/' } bugit_generate_id () { uuidgen 2>/dev/null || dd bs=1 count=16 </dev/urandom 2>/dev/null | md5sum | sed 's/ .*//' } bugit_add_attachments () { if [ $# -gt 0 ]; then # Add a timestamp to reduce the risk of conflict. date=$(date "+%Y-%m-%d %H:%M") mkdir -p attachments for f; do filename="$(date "+%Y-%m-%d %H:%M") - $(basename "$f")" cp "$f" "attachments/$filename" done git add attachments/ fi } ## Commands ################################################################### bugit_cmd_init () { # : Initialize a new bug database eval "$(bugit_make_optloop '')" git init "$@" git commit --allow-empty -m 'Initial commit' } bugit_cmd_new () { # [--author AUTHOR] NAME [ATTACHMENTS...] eval "$(bugit_make_optloop 'author=' name ...)" [ ! "" = "$author" ] || author=$(bugit_author) id=$(bugit_generate_id) bugit_assert_clean_p git checkout --orphan bugs/"$id" || user_error "Can't create branch 'bugs/$id'" git rm -rf . mkdir -p followers bugit_add_attachments "$@" touch followers/"$author" echo "$name" >name git add . git commit # Now record the name->id mapping in the master branch. git checkout master filename=$(bugit_to_filename "$name") mkdir -p names/"$filename" touch names/"$filename"/"$id" git add names/"$filename"/"$id" git commit -m "Add name->id mapping for $name" } bugit_cmd_reply () { # [--author AUTHOR] BUG [ATTACHMENTS...] eval "$(bugit_make_optloop 'author=' bug ...)" [ ! "" = "$author" ] || author=$(bugit_author) bugit_get_id "$bug"; bugit_checkout_id "$id" bugit_add_attachments "$@" git commit --allow-empty } bugit_cmd_show () { # BUG : Display the bug's content eval "$(bugit_make_optloop '' bug)" bugit_get_id "$bug" git log --no-merges --reverse bugs/"$id" } bugit_cmd_list () { # : List all bugs in the database eval "$(bugit_make_optloop '')" bugit_checkout_master for id in $(git branch -a --list 'bugs/*' '*/bugs/*' | sed 's|^..\(.*/\)\?bugs/||' | sort -u); do bugit_get_name "$id" || name="" if bugit_get_number "$id"; then echo "bug#$number: $name" else echo "$id: $name" fi done } bugit_cmd_number () { # BUG... : Assign numbers to bugs eval "$(bugit_make_optloop '' ...)" # TODO: Allow several BUGs at a time, or [ $# -gt 0 ] || # FIXME: when no BUG is specified, we should do it for all # un-numbered bugs. user_error "Have to identify bugs explicitly" bugit_checkout_master for bug; do bugit_get_id "$bug" nb=$(cd numbers 2>/dev/null && grep -l "$id" * 2>/dev/null) [ "" = "$nb" ] || { echo "Already assigned number $nb to bug '$bug'" continue } mkdir -p numbers # FIXME: Randomize this number somewhat, so that bug-numbering # can be done offline as well! last=$( (echo 0; ls numbers) | sort -n | tail -n 1) nb=$(($last + 1)) echo "$id" >"numbers/$nb" git add "numbers/$nb" done git commit -m "Assign some bug numbers" } bugit_cmd_push () { # [--subset] REMOTE [BUG] eval "$(bugit_make_optloop 'subset' [remote] [bug])" [ ! "" = "$remote" ] || remote=origin # FIXME: If we don't push "master", the remote "master" will have # an incomplete "names" subdir! Maybe we could fix it lazily (by improving # the "names" cache so we can detect its staleness) or with a push-hook. if [ "true" = "$subset" ]; then [ "" = "$bug" ] || invalid "Option --subset is redundant with $bug" git push "$remote" 'bugs/*:bugs/*' elif [ "" = "$bug" ]; then # FIXME: We can't "--prune" here since the remote may have some new # branches. That basically means there's no way to remove bugs/ID # since the next "bugit push" will bring it right back! git push --all "$remote" else bugit_get_id "$bug" git push "$remote" "bugs/$id:bugs/$id" fi } bugit_cmd_pull () { eval "$(bugit_make_optloop 'bugsonly' [remote] [bug])" # TODO: Same as push, with single bug, and matching bugs-only. [ ! "" = "$remote" ] || remote=origin if [ "true" = "$subset" ]; then [ "" = "$bug" ] || invalid "Option --subset is redundant with $bug" pattern="bugs/" elif [ "" = "$bug" ]; then pattern="" else bugit_get_id "$bug" pattern="bugs/$id" fi git fetch "$remote" for branch in $(git for-each-ref --format "%(refname)" \ "refs/remotes/$remote/$pattern"); do local=$(echo "$branch" | sed 's|refs/remotes/[^/]*/||') if [ "HEAD" = "$local" ]; then echo "Skipping HEAD" elif ! git show-ref -q --verify "refs/heads/$local"; then echo "branch $branch has no local equivalent" elif [ "" = "$(git rev-list "$local..$branch")" ]; then echo "branch $branch has nothing new" else bugit_assert_clean_p git checkout "$local" bugit_merge "$local" "$branch" fi done } bugit_cmd_id () { # BUG : Return the id of BUG eval "$(bugit_make_optloop '' bug)" bugit_get_id "$bug" echo "$id" } bugit_cmd_name () { # BUG : Return the name of BUG eval "$(bugit_make_optloop '' bug)" bugit_get_id "$bug" bugit_get_name "$id" echo "$name" } bugit_cmd_rename () { # BUG NEWNAME eval "$(bugit_make_optloop '' bug newname ...)" bugit_get_id "$bug" bugit_get_name "$id" bugit_checkout_id "$id" echo "$newname" "$@" >name git add name git commit -m "Rename" bugit_checkout_master [ "" = "$name" ] || rm -f "names/$(bugit_to_filename "$name")/$id" mkdir -p "names/$(bugit_to_filename "$newname")" touch "names/$(bugit_to_filename "$newname")/$id" git add names git commit -m "Rename $bug" } bugit_cmd_tag () { # BUG TAG eval "$(bugit_make_optloop '' bug tag)" set -- $tag [ $# = 1 ] || user_error "Invalid tag name '$tag'" bugit_get_id "$bug" bugit_checkout_id "$id" mkdir -p tags touch "tags/$tag" || user_error "Invalid tag name '$tag'" git add "tags/$tag" git commit -m 'Add tag' } bugit_cmd_untag () { # BUG TAG eval "$(bugit_make_optloop '' bug tag)" set -- $tag [ $# = 1 ] || user_error "Invalid tag name '$tag'" bugit_get_id "$bug" bugit_checkout_id "$id" rm -f "tags/$tag" git add "tags/$tag" git commit -m 'Remove tag' } bugit_cmd_taglist () { # BUG eval "$(bugit_make_optloop '' bug)" bugit_get_id "$bug" bugit_get_branch "$id" tags="" echo $(git cat-file -p "$branch:tags" | while read mode type hash name; do echo $name done) } bugit_cmd_assign () { # [--only] BUG EMAIL eval "$(bugit_make_optloop 'only' bug email)" bugit_get_id "$bug" bugit_checkout_id "$id" mkdir -p assigned-to if [ "true" = "only" ]; then rm -f assigned-to/*; fi touch "assigned-to/$email" || user_error "Invalid email name '$email'" git add "assigned-to/" git commit -m 'Assign' } bugit_cmd_unassign () { # BUG EMAIL eval "$(bugit_make_optloop '' bug email)" bugit_get_id "$bug" bugit_checkout_id "$id" rm -rf "assigned-to/$email" git add "assigned-to/" git commit -m 'Unassign' } bugit_cmd_assigned () { # BUG eval "$(bugit_make_optloop '' bug)" bugit_get_id "$bug" bugit_get_branch "$id" git cat-file -p "$branch:assigned-to" | while read mode type hash name; do echo "$name" done } bugit_cmd_follow () { # BUG EMAIL eval "$(bugit_make_optloop '' bug email)" bugit_get_id "$bug" bugit_checkout_id "$id" mkdir -p followers touch "followers/$email" || user_error "Invalid email name '$email'" git add "followers/" git commit -m 'Add follower' } # TODO: Severity # [ "$#" -gt 0 ] || invalid "BuGit usage:" cmd=$1; shift type "bugit_cmd_$cmd" >/dev/null || invalid "Unknown BuGit command '$cmd'" "bugit_cmd_$cmd" "$@" -- To unsubscribe from this list: send the line "unsubscribe git" in the body of a message to majordomo@xxxxxxxxxxxxxxx More majordomo info at http://vger.kernel.org/majordomo-info.html