Initial commit
authorFrantisek Hrbata <frantisek@hrbata.com>
Tue, 1 Dec 2020 13:23:13 +0000 (14:23 +0100)
committerFrantisek Hrbata <frantisek@hrbata.com>
Tue, 1 Dec 2020 13:23:13 +0000 (14:23 +0100)
Signed-off-by: Frantisek Hrbata <frantisek@hrbata.com>
24 files changed:
COPYING [new file with mode: 0644]
Documentation/git-mr-create.txt [new file with mode: 0644]
Documentation/git-mr-fetch.txt [new file with mode: 0644]
Documentation/git-mr-mbox.txt [new file with mode: 0644]
Documentation/git-mr-mbox.txt.bak [new file with mode: 0644]
Documentation/git-mr-push.txt [new file with mode: 0644]
Documentation/git-mr-remove.txt [new file with mode: 0644]
Documentation/git-mr-sendmail.txt [new file with mode: 0644]
Documentation/git-mr-show.txt [new file with mode: 0644]
Documentation/git-mr-tag.txt [new file with mode: 0644]
Documentation/git-mr-update.txt [new file with mode: 0644]
Documentation/git-mr.txt [new file with mode: 0644]
Makefile [new file with mode: 0644]
gimrr.spec [new file with mode: 0644]
git-mr-create [new file with mode: 0755]
git-mr-fetch [new file with mode: 0755]
git-mr-mbox [new file with mode: 0755]
git-mr-push [new file with mode: 0755]
git-mr-remove [new file with mode: 0755]
git-mr-sendmail [new file with mode: 0755]
git-mr-show [new file with mode: 0755]
git-mr-tag [new file with mode: 0755]
git-mr-update [new file with mode: 0755]
mrlib.py [new file with mode: 0755]

diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..5a8e332
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,14 @@
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+                    Version 2, December 2004
+
+ Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. You just DO WHAT THE FUCK YOU WANT TO.
+
diff --git a/Documentation/git-mr-create.txt b/Documentation/git-mr-create.txt
new file mode 100644 (file)
index 0000000..cccde12
--- /dev/null
@@ -0,0 +1,81 @@
+GIT-MR-CREATE(1)
+================
+:doctype: manpage
+
+
+NAME
+----
+git-mr-create - Create new merge request
+
+
+SYNOPSIS
+--------
+*git-mr-create* ['OPTIONS'] ['REV']
+
+
+DESCRIPTION
+-----------
+Creates a new MR (merge request) from 'REV', which is a revision in
+whatever form is accepted by git. If 'REV' is not specified, 'HEAD'
+is used as the default value. New MR is identified by the full 'SHA-1' of 'REV'
+and a new reference is created under the 'mrs' refs namespace
+'refs/mrs/<mr-sha-1>/<user>/head'.
+
+Each MR has to have a base specified with the *-b* argument. It's a 'REV' the
+MR is based on.
+
+Description for the new MR is taken from *-m*, *-F* arguments or from *-*
+'stdin'. The description is in the same format as for 'git-commit'. If none
+of these options is used, editor is started by default.
+
+
+OPTIONS
+-------
+*-b, --base* 'REV'::
+    Base revision of the MR.
+
+*-, --stdin*::
+    Read MR description from 'stdin'. Description entered via *-m* or *-F*
+    option is ignored.
+
+*-f, --file* 'FILE'::
+    Use MR description from 'FILE'. Description entered via *-m* option is
+    ignored.
+
+*-m, --message 'MSG'::
+    Use 'MSG' as MR description. Multiple 'MSG's are concatenated as separate
+    paragraphs.
+
+EXAMPLES
+--------
+Create a new MR based on 'master' branch.
+
+`$ git checkout -b feature master`::
+  Checkout a new branch 'feature' based on 'master' and add your changes.
+
+`$ git mr-create -b master -m "My new feature"`::
+  Create a new MR from the 'feature' branch based on 'master'.
+
+`$ git mr-show`::
+  Show some basic info about the newly created MR. 'git-mr-show' can display
+  all sort of information in a user specific formats.
+
+`$ git mr-tag -A -a -t bz -v 12345`::
+  Attach 'bz' tag to all commits in MR. Tags are dynamic and don't
+  have to be predefined anywhere. Once tag is added, it can be
+  displayed along with other information with 'git-mr-show'.
+
+`$ git mr-tag -a -t N -v "This is my MR note"`::
+  Attach 'N' tag with note to MR only.
+
+`$ git mr-show -c --cfmt="{bz} {h} {s}"`::
+  Display commits for MR in a specific format with
+  the 'bz' tag included.
+
+`$ git mr-push"`::
+  If all is as expected, push MR for review to remote.
+
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-fetch.txt b/Documentation/git-mr-fetch.txt
new file mode 100644 (file)
index 0000000..cf80e29
--- /dev/null
@@ -0,0 +1,78 @@
+GIT-MR-FETCH(1)
+===============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-fetch - Download merge request(s), tags and mboxes from remote
+
+
+SYNOPSIS
+--------
+*git-mr-fetch* ['OPTIONS'] ['REV' ['REV' ...]]
+
+
+DESCRIPTION
+-----------
+This is just a wrapper around 'git-fetch', which downloads refs in the 'mrs'
+namespace from remote. By default only refs in other user namespaces are
+fetched. This can be changed by the *-f* option, but all local changes in
+your namespace will be lost.
+
+If no 'REV' is specified, 'HEAD' is used as the default MR. Otherwise
+'REV's specified as positional arguments or on *-* stdin are used. The *-A*
+option can be used to fetch new remote refs and to update all local refs,
+except for the ones owned by you.
+
+By default all three types of refs are downloaded. This can be changed by
+*--head*, *--tags* and *--mbox* options.
+
+
+OPTIONS
+-------
+*-A, --all*::
+    Fetch all MR refs from remote, except for yours. To fully sync
+    with remote use the *-f* option, but note that this will overwrite any
+    local(unpushed) changes you may have. MRs entered as positional arguments or
+    on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*-r, --remote* 'REMOTE'::
+    The remote repository to fetch from, default is origin.
+
+*--head*::
+    Fetch MR commits. These are head refs in the 'mrs'
+    namespace.
+
+*--tags*::
+    Fetch tags refs. These are tags refs in the 'mrs' namespace.
+
+*--mbox*::
+    Fetch mboxes refs. These are mbox refs in the 'mrs' namespace.
+
+*-f, --force*::
+    Fetch also refs your refs. All local changes will be lost!
+
+*-q, --quiet*::
+    Progress is not reported to the standard error stream.
+
+EXAMPLES
+--------
+`$ git mr-fetch`::
+  Fetch refs for current MR specified by 'HEAD'.
+
+`$ git mr-fetch -A`::
+  Fetch refs for all MRs from origin remote.
+
+`$ git mr-fetch -A -r upstream`::
+  Fetch refs for all MRs from upstream remote.
+
+`$ git mr-fetch -A -r upstream --tags --mbox`::
+  Same as previous, but fetch only tags and mboxes.
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-mbox.txt b/Documentation/git-mr-mbox.txt
new file mode 100644 (file)
index 0000000..47a18dc
--- /dev/null
@@ -0,0 +1,125 @@
+GIT-MR-MBOX(1)
+==============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-mbox - Generate mbox for merge request(s) and print it to stdout
+
+
+SYNOPSIS
+--------
+*git-mr-mbox* ['OPTIONS'] ['MR' ['MR' ...]]
+
+
+DESCRIPTION
+-----------
+This command, if used in combination with 'git-mr-fetch', 'git-mr-push'
+and 'git-mr-sendmail' provides a possibility to do MR reviews in email
+based form, using your favorite MUA like Mutt. It generates mbox file
+from 'head' and 'mbox' refs stored in the 'mrs' refs namespace. This
+replaces fetching mail from SMTP server and mbox is generated from data
+stored in git only.
+
+Please check the *MUTT* section to see one of possible and basic
+setups.
+
+OPTIONS
+-------
+*-A, --all*::
+    Generate mbox for all MRs. MRs entered as positional arguments or
+    on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*-r, --read-refs* 'FILE'::
+    Read refs from 'FILE'. If used, mbox is generated only for refs not
+    presented in 'FILE'. This can be used to generate mbox for new MRs and
+    new comments only, simulating fetching of new mail from SMTP server.
+
+*-w, --write-refs* 'FILE'::
+    Write refs for MRs and comments in the generated mbox to a 'FILE'. It
+    can be the same 'FILE' as for the *-r* option. You can incrementally
+    update the mbox by specifying both *-r* and *-w* options and using the
+    same 'FILE.' Only newly added or updated refs are changed.
+
+*-m, --mbox* 'FILE'::
+    Append mbox to 'FILE' instead of writing it to stdout.
+
+
+MUTT
+----
+This section provides basic Mutt setup for MR reviews.
+
+Mutt config file that can be used. For the purpose
+of this example, please store it as '~/.gimrr/muttrc'.
+----
+set realname  = "your name"
+set from      = "your email address"
+set sendmail  = "git mr-sendmail"
+set sort = threads
+set timeout = 10
+----
+
+Add the following aliases to your git config.
+----
+mr-mutt = "!sh -c 'dir=${HOME}/.gimrr; mkdir -p $dir && \
+                mr=$(git rev-parse --verify ${1:-HEAD}) && \
+                git mr-mbox -m ${dir}/${mr}.mbox -r ${dir}/${mr}.refs -w ${dir}/${mr}.refs $mr \
+                && mutt -f ${dir}/${mr}.mbox -F ${dir}/muttrc'" git-mr-review
+
+mr-mutt-all = "!sh -c 'dir=${HOME}/.gimrr; mkdir -p $dir && \
+                git mr-mbox -A -m ${dir}/all.mbox -r ${dir}/all.refs -w ${dir}/all.refs && \
+                mutt -f ${dir}/all.mbox -F ${dir}/muttrc'" git-mr-review-all
+----
+
+The 'git-mr-mutt' alias will generate mbox and start Mutt for current
+MR(HEAD). You can pass it MR sha1 as argument to review a different MR.
+The 'git-mr-mutt-all' alias generates mbox for all MRs
+in the current git repo. You can also easily reply to patches or comments
+from others from within git, because 'git-mr-sendmail' is set for mail
+delivery.
+
+Note that your replies and MRs are created locally and you need to push them.
+The same goes for changes made by others. You need to fetch them. Hence
+you should run 'git mr-fetch -A' and 'git mr-push -A --mbox' at some point.
+
+You can use something like following in cron job or loop to simulate mail
+synchronization for all MRs in specific repo.
+----
+REPO="path to git repo"
+NAME="$(basename $REPO)"
+DIR="${HOME}/.gimrr"
+git -C $REPO mr-fetch -A
+git -C $REPO mr-push -A --mbox
+git -C mr-mbox -A -m ${DIR}/${NAME}.mbox -r ${DIR}/${NAME}.refs -w ${DIR}/${NAME}.refs
+----
+
+EXAMPLES
+--------
+`$ git mr-mbox MR1 MR2`::
+  Generate mbox for MR1 and MR2 to stdout.
+
+`$ git mr-mbox`::
+  Generate mbox for current MR(HEAD) to stdout.
+
+`$ git mr-mbox -m mbox`::
+  Same as above but store the mbox to 'mbox' file.
+
+`$ git mr-mbox -A -m mbox`::
+  Generate 'mbox' file for all available MRs and their comments.
+
+`$ git mr-mbox -A -m mbox -r mbox.refs -w mbox.refs`::
+  Same as above, but also reads and stores refs for which
+  the 'mbox' file was generated. It can be called repeatedly
+  and only new MRs and comments will be appended to mbox.
+  Note the this should be used along with 'git-mr-fetch' to
+  actually get the changes from server, even though your unpushed
+  MRs and comments will be available right away.
+
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-mbox.txt.bak b/Documentation/git-mr-mbox.txt.bak
new file mode 100644 (file)
index 0000000..8f7a6ad
--- /dev/null
@@ -0,0 +1,125 @@
+GIT-MR-MBOX(1)
+==============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-mbox - Generate mbox for merge request(s) and print it to stdout
+
+
+SYNOPSIS
+--------
+*git-mr-mbox* ['OPTIONS'] ['MR' ['MR' ...]]
+
+
+DESCRIPTION
+-----------
+This command, if used in combination with 'git-mr-fetch', 'git-mr-push'
+and 'git-mr-sendmail' provides a possibility to do MR reviews in email
+based form, using your favorite MUA like Mutt. It generates mbox file
+from 'head' and 'mbox' refs stored in the 'mrs' refs namespace. This
+replaces fetching mail from SMTP server and mbox is generated from data
+stored in git only.
+
+Please check the *MUTT* section to see one of possible and basic
+setups.
+
+OPTIONS
+-------
+*-A, --all*::
+    Generate mbox for all MRs. MRs entered as positional arguments or
+    on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*-r, --read-refs* 'FILE'::
+    Read refs from 'FILE'. If used, mbox is generated only for refs not
+    presented in 'FILE'. This can be used to generate mbox for new MRs and
+    new comments only, simulating fetching of new mail from SMTP server.
+
+*-w, --write-refs* 'FILE'::
+    Write refs for MRs and comments in the generated mbox to a 'FILE'. It
+    can be the same 'FILE' as for the *-r* option. You can incrementaly
+    update the mbox by specifying both *-r* and *-w* options and using the
+    same 'FILE.' Only newly added or updated refs are changed.
+
+*-m, --mbox* 'FILE'::
+    Append mbox to 'FILE' instead of writing it to stdout.
+
+
+MUTT
+----
+This section provides basic Mutt setup for MR reviews.
+
+Basic Mutt config file that can be used. For the purpose
+of this example, please store it as '~/.gimrr/muttrc'.
+----
+set realname  = "your name"
+set from      = "your email address"
+set sendmail  = "git mr-sendmail"
+set sort = threads
+set timeout = 10
+----
+
+Add the following aliases to your git config.
+----
+mr-mutt = "!sh -c 'dir=${HOME}/.gimrr; mkdir -p $dir && \
+                mr=$(git rev-parse --verify ${1:-HEAD}) && \
+                git mr-mbox -m ${dir}/${mr}.mbox -r ${dir}/${mr}.refs -w ${dir}/${mr}.refs $mr \
+                && mutt -f ${dir}/${mr}.mbox -F ${dir}/muttrc'" git-mr-review
+
+mr-mutt-all = "!sh -c 'dir=${HOME}/.gimrr; mkdir -p $dir && \
+                git mr-mbox -A -m ${dir}/all.mbox -r ${dir}/all.refs -w ${dir}/all.refs && \
+                mutt -f ${dir}/all.mbox -F ${dir}/muttrc'" git-mr-review-all
+----
+
+The 'git-mr-mutt' alias will generate mbox and start Mutt for current
+MR(HEAD). You can pass it MR sha1 as argument to review a different MR.
+The 'git-mr-mutt-all' alias generates mbox for all MRs
+in the current git repo. You can also easily reply to patches or comments
+from others from within git, because 'git-mr-sendmail' is set for mail
+delivery.
+
+Note that your replies and MRs are created localy and you need to push them.
+The same goes for changes made by others. You need to fetch them. Hence
+you should run 'git mr-fetch -A' and 'git mr-push -A --mbox' at some point.
+
+You can use something like following in cron job or loop to simulate mail
+synchronization for all MRs in specific repo.
+----
+REPO="path to git repo"
+NAME="myrepo"
+DIR="${HOME}/.gimrr"
+git -C "REPO" mr-fetch -A
+git -C "REPO" mr-push -A --mbox
+git -C mr-mbox -A -m $(DIR)/$(NAME).mbox -r $(DIR)/$(NAME).refs -w $(DIR)/$(NAME).refs`
+----
+
+EXAMPLES
+--------
+`$ git mr-mbox MR1 MR2`::
+  Generate mbox for MR1 and MR2 to stdout.
+
+`$ git mr-mbox`::
+  Generate mbox for current MR(HEAD) to stdout.
+
+`$ git mr-mbox -m mbox`::
+  Same as above but store the mbox to 'mbox' file.
+
+`$ git mr-mbox -A -m mbox`::
+  Generate 'mbox' file for all available MRs and their comments.
+
+`$ git mr-mbox -A -m mbox -r mbox.refs -w mbox.refs`::
+  Same as above, but also reads and stores refs for which
+  the 'mbox' file was generated. It can be called repeatedly
+  and only new MRs and comments will be appended to mbox.
+  Note the this should be used along with 'git-mr-fetch' to
+  actually get the chages from server, even though your unpushed
+  MRs and comments will be available right away.
+
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-push.txt b/Documentation/git-mr-push.txt
new file mode 100644 (file)
index 0000000..ae79a1e
--- /dev/null
@@ -0,0 +1,70 @@
+GIT-MR-PUSH(1)
+==============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-push - Update remote merge request(s), tags and mboxes
+
+
+SYNOPSIS
+--------
+*git-mr-push* ['OPTIONS'] ['REV' ['REV' ...]]
+
+
+DESCRIPTION
+-----------
+This is just a wrapper around 'git-push', which pushes refs in your refs
+namespaces for specified 'REV's to remote.
+
+If no 'REV' is specified, 'HEAD' is used as the default MR. Otherwise
+'REV's specified as positional arguments or on *-* stdin are used. The *-A*
+option can be used to push all your local changes to remote.
+
+By default all three types of refs are pushed. This can be changed by
+*-head*, *--tags* and *--mbox* options.
+
+
+OPTIONS
+-------
+*-A, --all*::
+    Push all your MR refs to remote. MRs entered as positional arguments or
+    on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*-r, --remote* 'REMOTE'::
+    The remote repository to push to, default is origin.
+
+*--head*::
+    Push MR commits. These are head refs in your 'mrs'
+    namespace.
+
+*--tags*::
+    Push tags refs. These are tags refs in your 'mrs' namespace.
+
+*--mbox*::
+    Push mboxes refs. These are mbox refs in your 'mrs' namespace.
+
+*-q, --quiet*::
+    Progress is not reported to the standard error stream.
+
+EXAMPLES
+--------
+`$ git mr-push`::
+  Push your refs for current MR specified by 'HEAD'.
+
+`$ git mr-push -A`::
+  Push your refs for all MRs to origin remote.
+
+`$ git mr-push -A -r upstream`::
+  Push your refs for all MRs to upstream remote.
+
+`$ git mr-push -A -r upstream --tags --mbox`::
+  Same as previous, but push only tags and mboxes.
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-remove.txt b/Documentation/git-mr-remove.txt
new file mode 100644 (file)
index 0000000..84b5ed1
--- /dev/null
@@ -0,0 +1,67 @@
+GIT-MR-REMOVE(1)
+================
+:doctype: manpage
+
+
+NAME
+----
+git-mr-remove - Remove merge request(s), tags and mboxes
+
+
+SYNOPSIS
+--------
+*git-mr-remove* ['OPTIONS'] ['REV' ['REV' ...]]
+
+
+DESCRIPTION
+-----------
+Removes refs from the 'mrs' refs namespace. Only local refs are removed and
+it has no effect on refs already pushed to remote. This is primary intended
+to remove MRs which should not be pushed.
+
+If no 'REV' is specified, 'HEAD' is used as the default MR. Otherwise
+'REV's specified as positional arguments or on *-* stdin are used. The *-A*
+option can be used to remove refs for all MRs.
+
+By default all three types of refs are removed. This can be changed by
+*--mrs*, *--tags* and *--mbs* options.
+
+
+OPTIONS
+-------
+*-A, --all*::
+    Use all available MRs as input. MRs entered as positional arguments or
+    on stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*--mrs*::
+    Remove merge request(s) refs.
+
+*--tags*::
+    Remove tags refs.
+
+*--mbs*::
+    Remove mboxes refs.
+
+*-u, --user* ['USER']::
+    Remove refs for specific user(s) only. This option can be used multiple
+    times. If used without 'USER', just as a switch, only your refs will be
+    removed.
+
+EXAMPLES
+--------
+`$ git mr-remove`::
+  Remove current MR specified by 'HEAD'.
+
+`$ git mr-remove -A`::
+  Remove all refs for all MRs.
+
+`$ git mr-remove -A --tags --mbs`::
+  Same as previous, but remove only tags and mboxes.
+
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-sendmail.txt b/Documentation/git-mr-sendmail.txt
new file mode 100644 (file)
index 0000000..a370c47
--- /dev/null
@@ -0,0 +1,51 @@
+GIT-MR-SENDMAIL(1)
+==================
+:doctype: manpage
+
+
+NAME
+----
+git-mr-sendmail - Simulate sendmail behavior to send reply to MR, commit or comment
+
+
+SYNOPSIS
+--------
+*git-mr-sendmail* ['OPTIONS'] ['RECIPIENT' ['RECIPIENT' ...]]
+
+
+DESCRIPTION
+-----------
+Reads mail from stdin or file and creates a new mbox entry under
+'mrs' refs namespace 'refs/mrs/<mr-sha-1>/<user>/mbox'. To make
+it visible to others you need to push your changes using 'git-mr-push'.
+
+The input should be normal mail reply and it has to contain *Subject*,
+*To* and *In-Reply-To* headers. The *In-Reply-To* header has to contain
+msgid in a format expected by 'git-mr-sendmail'. That is
+`<`*mr*`.`*commit*`@localhost>`, where *mr* is full 'SHA-1' of MR and
+*commit* is full 'SHA-1' of MR commit or mbox entry. Note that each
+mbox entry is also identified by hash. The mbox entries and they hashes
+can be seen by using e.g. 'git-log' on 'refs/mrs/<mr-sha-1>/<user>/mbox'.
+
+This command is primary intended to be used with MUAs, like Mutt, which
+allow to specify a program for mail delivery. Please don't forget to
+use git *-C* option if this runs outside of git repo.
+
+OPTIONS
+-------
+*-f, --file 'FILE'*::
+    Read mail from FILE instead of stdin.
+
+
+EXAMPLES
+--------
+`set sendmail  = "git mr-sendmail"`::
+  Set this option in your Mutt configuration to Use 'git-mr-sendmail'
+  for mail delivery.
+
+`set sendmail  = "git -C <path> mr-sendmail"`::
+  Set proper <path> for git repo.
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-show.txt b/Documentation/git-mr-show.txt
new file mode 100644 (file)
index 0000000..a1b1cd7
--- /dev/null
@@ -0,0 +1,158 @@
+GIT-MR-SHOW(1)
+==============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-show - Show information about merge request(s) and its commits
+
+
+SYNOPSIS
+--------
+*git-mr-show* ['OPTIONS'] ['REV' ['REV' ...]]
+
+
+DESCRIPTION
+-----------
+Displays tags for MRs and their commits using Python's format string syntax.
+(see *RESOURCES*). Tag names which can be used in the format,
+can be listed with the *-t* option. Basic tags are created by 'git-mr-create'
+and others can be added with 'git-mr-tag'. If tag is not available, it's
+displayed as defined in the format string.
+
+MRs, for which the information is displayed, can be specified as 'REV'
+positional arguments, on *-* stdin or with *-m*, *-A* option. These cannot
+be mixed and have a hard-coded precedence. If no MR is specified, 'HEAD' is
+used by default. Note that the MR identifier is just a full 'SHA-1' of 'REV'.
+
+By default only MRs information is displayed. To see commit info use *-c* option.
+You can specify two format strings. One for MRs *--mfmt* and one for commits
+*--cfmt*.
+
+In addition to Python's string format, each tag may have a sub-format and the full
+tag specification is *tag*@*fmt*@*sep*@*users*`:`*pfmt*. *tag* is the tag name
+as shown by the *-t* option. *fmt* is 'git-mr-show' specific format. It can
+be *v* to display all tag values(this is the default) or *c* to display number
+of values this tag has or *r* to display tag values along with users who set
+them. *sep* is a separator to use if the tag has multiple values, default is
+space. *users* is list of users, separated by comma and only values set by
+these users are displayed. This has higher precedence then *-u*. *pfmt* is
+Python's format specification. The '@' separator in format is mandatory if
+some parts are not specified. For example if you want to display tag 'bz'
+values only for user 'johndoe', you need to specify `{bz@@@johndoe}`.
+Note that each tag may have multiple values set by different users. This
+hopefully makes the format flexible enough to display desired information only.
+Please see *EXAMPLES*.
+
+Sometimes it may be useful to display tags for commits in the MR format. For
+example if you want to see what bugs are assigned to the MR. This can be done
+by using the *-s* option. It will squash all commit tags to MR and you can use
+the tag name in MR format even if there is actually no such tag attached.
+Note that tags are attached to commit hash and MR has zero commit hash(`0*40`).
+This is how the MR and commit tags are distinguished. The *-s* option just
+assigns all tags for commits to the MR zero hash. This also requires some
+caution when choosing tag names, so the MR and commit tags are not mixed
+together while the *-s* option is used. As a convention MR tags uses upper case
+letters. For example 'S' is subject for MR and 's' is subject for commit.
+
+OPTIONS
+-------
+*-m, --my*::
+    Show information for MRs created by me. MRs entered as positional
+    arguments, on *-* stdin or with *-A* option are ignored.
+
+*-A, --all*::
+    Show information for all MRs. MRs entered as positional arguments or
+    on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read MRs from stdin. MRs entered as positional arguments are ignored.
+
+*-c, --commits*::
+    Show also commits information. By default only MR information is displayed.
+
+*-s, --squash*::
+    Squash commit tags into MR. With this option all commit tags are attached
+    to MR and can be displayed in the MR summary. This disables *-c*.
+
+*--mfmt* 'FMT'::
+    Python's formatted string for MRs. You can specify which tags will be
+    displayed in MR description. Default value is
+    `"{M:.16} {B:.16} {CC:6.6} {S:60.60} {AN:16.16} {AE:>20.20} {AD}"`.
+
+*--cfmt* 'FMT'::
+    Python's formatted string for commits. You can specify which tags will be
+    displayed in commit description. Default value is
+    `"   {h:.16} {s}"`.
+
+*-t, --tags*::
+    List available tags for selected MRs. Use *-c* option if you want to list
+    tags for commits too.
+
+*-u, --user* ['USER']::
+    Show tags for specified user(s) only. Can be used multiple times. The users
+    list in tag format has higher precedence. If used without 'USER', just as
+    a switch, only your tags will be shown.
+
+EXAMPLES
+--------
+`$ git mr-show`::
+  Show info for current MR(HEAD) using the default format.
+
+`$ git mr-show -c`::
+  Same as previous, but also show info about each commit in the MR using the
+  default format.
+
+`$ git mr-show -c -u`::
+  Same as previous, but only tag values set my me(`git config mr.user`).
+
+`$ git mr-show -A`::
+  Show info about all MRs.
+
+`$ git mr-show -m -c`::
+  Show info about MRs and commits created by me(`git config mr.user`).
+
+`$ git mr-show -c --cfmt="{bz} {h} {s}" feature`::
+  Show MR info and commits info for MR identified by branch feature.
+  The comments format will show bugzilla, commit hash and commit
+  subject.
+
+`$ git mr-show -c --mfmt="{M} {S}" --cfmt="{bz} {h} {s}"`::
+  Same as previous, but also use specific format for MR, which
+  will display MR hash(id) and subject.
+
+`$ git mr-show -c --cfmt="{bz@@|}`::
+  Show commit bz tag values only and use '|' as separator.
+
+`$ git mr-show -c --cfmt="{bz@@|@johndoe}`::
+  Same as previous, but also show values added by 'johndoe' only.
+
+`$ git mr-show -c --cfmt="{bz@@|@johndoe,max,hugo}`::
+  Same as previous limiting the values for more users.
+
+`$ git mr-show -c --cfmt="{bz@c}`::
+  Show number of bz tag values.
+
+`$ git mr-show -c --cfmt="{bz@r@, }`::
+  Same as previous, but show users who set the bz values and
+  separate them with ', '.
+
+`$ git mr-show -A -s --mfmt="{M} {bz@r@,}"`::
+  Squash all tags for commits to MR and show MR hash along with bz
+  tag values. Values are displayed with users who set them and separated
+  by comma.
+
+`$ git mr-show -A -s --mfmt="{M} {bz@r@,:^20.20}"`::
+  Same as previous, but also uses Python's format spec to set
+  maximum column width and align the bz values in center.
+
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
+
+
+RESOURCES
+---------
+Python's format string syntax: <https://docs.python.org/3.2/library/string.html#formatstrings>
diff --git a/Documentation/git-mr-tag.txt b/Documentation/git-mr-tag.txt
new file mode 100644 (file)
index 0000000..5192e5a
--- /dev/null
@@ -0,0 +1,99 @@
+GIT-MR-TAG(1)
+=============
+:doctype: manpage
+
+
+NAME
+----
+git-mr-tag - Add/remove tag for merge request or commits
+
+
+SYNOPSIS
+--------
+*git-mr-tag* ['OPTIONS'] ['REV' ['REV' ...]]
+
+
+DESCRIPTION
+-----------
+Allows to attach tag to MR or commits. Each commit is identified by its
+'REV' (hash). MR is a special case with zero 'REV' (0*40) and if no
+commits are specified, it's used by default. This means you can attach tag
+to each commit, but also to the MR as a whole. Tag name can consist of
+alphanumeric chars only. Convention is to use uppercase letters for MR tags,
+because commit tags can be squashed into the MR commit in 'git-mr-show' if the
+*-s* option is used. This helps to avoid mixing of MR and commit tags.
+If MR is not specified with the *-m* option, 'HEAD' is used by default.
+Note that MR has to be specified along with the commits and commits have to be
+part of the MR.
+
+User tags have reference under MR and user namespace
+'refs/mrs/<mr-sha-1>/<user>/tags' and tags are stored separately for each user.
+Any change to tags creates a new internal commit and proper ref is updated
+accordingly. The tip of tags ref always contains the full set of user's tags.
+The raw format can be displayed with
+'git show refs/mrs/<mr-sha-1>/<user>/tags:tags'.
+
+Each tag can have multiple values set by different users, so each tag value
+is tight with user who set it. This allows 'git-mr-show' to display tag
+values for selected users only. Also different users can use the same tag name
+and assign it different values. Tag is uniquely identified with tag name, value
+and user who set this value.
+
+New tag value can be added with the *-a* option. If tag already exists, it's not
+added. Only unique tags are stored. Removing tag with the *-r* option has to
+modes. If value is specified with *-v*, only tag with this particular value
+is removed. Without *-v* all tag values are removed.
+
+
+OPTIONS
+-------
+*-A, --all*::
+    Add tag to all commit(s) in MR. Commit(s) entered as positional arguments
+    or on *-* stdin are ignored.
+
+*-, --stdin*::
+    Read commit(s) from stdin. Commit(s) entered as positional arguments are ignored.
+
+*-m, --mr 'REV'*::
+    Merge request containing commit(s) (default: HEAD).
+
+*-a, --add*::
+    Add tag.
+
+*-r, --remove*::
+    Remove tag. If *-v* is not used remove all values for tag specified by *-t*.
+
+*-t, --tag* 'TAG'::
+    Specify 'TAG' name. It can contain alphanumeric chars only.
+
+*-v, --value* 'VALUE'::
+    Specify tag 'VALUE'. If used with *-r* only tags with this value
+    are removed.
+
+
+EXAMPLES
+--------
+`$ git mr-tag -a -t NOTE -v "This is my MR note"`::
+  Add tag name NOTE with specified value to the MR identified by HEAD.
+
+`$ git mr-tag -A -a -t bz -v 12345`::
+  Add tag name bz to all commits in MR identified by HEAD.
+
+`$ git mr-tag -A -a -t bz -v 12345 -m feature`::
+  Same as previous, but MR is identified by the tip of feature branch.
+  Any git's rev expression can be used as long as it's valid MR. Meaning
+  MR is created for such REV.
+
+`$ git mr-tag -A -r -t bz -v 12345`::
+  Remove value 12345 from tag bz for all commits in MR identified by HEAD.
+
+`$ git mr-tag -A -r -t bz`::
+  Remove all values from tag bz for all commits in MR identified by HEAD.
+
+`$ git mr-tag -a -t bz -v 12345 -m feature ac13f5a23158286d`::
+  Remove value 12345 from tag bz from commit ac13f5a23158286d in MR identified
+  by tip of feature branch.
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr-update.txt b/Documentation/git-mr-update.txt
new file mode 100644 (file)
index 0000000..b34c525
--- /dev/null
@@ -0,0 +1,35 @@
+GIT-MR-UPDATE(1)
+================
+:doctype: manpage
+
+
+NAME
+----
+git-mr-update - default git 'update' hook
+
+
+SYNOPSIS
+--------
+*git-mr-update*
+
+
+DESCRIPTION
+-----------
+This is not intended to be run directly from command line. It's the default
+push policy checker for 'gimmr'. Just create a symlink 'update' pointing to
+'git-mr-update' in git's 'hooks' directory on your server.
+
+DEFAULT PUSH POLICY
+-------------------
+* Users listed in 'git config mr.admins' are not restricted
+* Fast-forward ref updates only
+* MR refs('head') can be created only, no updates are allowed
+* Only refs in user's namespace can be updated('tags', 'mbox')
+* Refs cannot be deleted
+* Updates to 'tags' and 'mbox' refs are checked to be in expected format
+* Only admins and user who created the MR are allowed to modify global
+  MR tags
+
+SEE ALSO
+--------
+link:git-mr.html[git-mr(1)]
diff --git a/Documentation/git-mr.txt b/Documentation/git-mr.txt
new file mode 100644 (file)
index 0000000..f604776
--- /dev/null
@@ -0,0 +1,82 @@
+GIT-MR(1)
+=========
+:doctype: manpage
+
+
+NAME
+----
+git-mr - Set of simple tools to manage merge requests and their review within git
+
+
+SYNOPSIS
+--------
+*git-mr*
+
+
+DESCRIPTION
+-----------
+'GIMRR' is abbreviation for **G**it **I**ntegrated **M**erge **R**equest **R**eview.
+It's goal is to provide a simple, but effective approach how to manage and review
+MRs in git. All data related to MRs are stored within git and 'git-mr' is just a
+set of Python scripts to make this data available to users in some reasonable form.
+This allows 'git-mr' to leverage git's transport protocol, distributed approach and
+possibility to work offline.
+
+Once a MR is created by link:git-mr-create.html[git-mr-create(1)], you can
+attach arbitrary tags to it or it's commits with link:git-mr-tag.html[git-mr-tag(1)]
+and present them in a customizable format with
+link:git-mr-show.html[git-mr-show(1)]. Review of MR can be done within your
+favorite MUA, because 'git-mr' can generate mboxes with
+link:git-mr-mbox.html[git-mr-mbox(1)] and link:git-mr-sendmail.html[git-mr-sendmail(1)]
+can be used for mail delivery, which in context of 'git-mr' is creating a new
+user's mbox entry. This should provide environment similar to email based
+distribution of patches and their review. Since 'git-mr' just presents data
+stored in git, other approaches and review methods can be implemented.
+
+All data are tracked within 'refs/mrs' refs namespace, where MRs are stored. MR is
+identified by full SHA-1, which is a tip of a branch from which the MR was created.
+This SHA-1 is also an unique identifier of the MR, which has several advantages.
+You can pass MR to 'git-checkout' and use it as normal branch. Once on that branch
+you don't need to specify MR for 'git-mr' commands, because by default they use
+HEAD as a MR identifier. Each user has it's own refs namespace for each MR under
+specified username. This allows to enforce a push policy easily, because each user
+is allowed to push only his namespace.
+
+The full 'git-mr' ref format is 'refs/mrs/<mr>/<username>/{head,tags,mbox}', where
+'<mr>' is MR's SHA-1 identifier, <username> is username as registered on server
+and '{head,tags,mbox}' are three ref types used by 'git-mr', which are described
+in *REF TYPES* section.
+
+REF TYPES
+---------
+This is the core of 'git-mr' and all data are tracked using the following three
+refs.
+
+head
+~~~~
+This is just a ref to the commit from which the MR was created.
+
+tags
+~~~~
+Ref to commit with user's current set of tags for a specific MR. It's
+a git tree with a single 'tags' files in a specific format. It can be
+viewed by using e.g. 'git show refs/mrs/<mr>/<username>/tags:tags'.
+
+mbox
+~~~~
+Ref to user's comments for a specific MR. It's an empty git tree and each
+commit represents user's reply, which is stored as commit description.
+These refs are used to generate MR mboxes and the format
+can be viewed by using e.g. 'git log refs/mrs/<mr>/<username>/mbox'.
+
+SEE ALSO
+--------
+link:git-mr-show.html[git-mr-show(1)]
+link:git-mr-create.html[git-mr-create(1)]
+link:git-mr-tag.html[git-mr-tag(1)]
+link:git-mr-fetch.html[git-mr-fetch(1)]
+link:git-mr-push.html[git-mr-push(1)]
+link:git-mr-remove.html[git-mr-remove(1)]
+link:git-mr-sendmail.html[git-mr-sendmail(1)]
+link:git-mr-update.html[git-mr-update(1)]
+link:git-mr-mbox.html[git-mr-mbox(1)]
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..0474287
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,84 @@
+.PHONY: clean rpm doc man html install uninstall clean_man clean_html
+
+all::
+
+RM = rm -f
+INSTALL = install
+RPMBUILD = rpmbuild
+
+DESTDIR =
+DOCDIR = Documentation
+MANDIR = $(DOCDIR)/man
+HTMLDIR = $(DOCDIR)/html
+
+prefix = /usr/local
+bindir = $(prefix)/bin
+mandir = $(prefix)/share/man
+man1dir = $(mandir)/man1
+
+BIN_FILES = git-mr-create \
+      git-mr-fetch \
+      git-mr-mbox \
+      git-mr-push \
+      git-mr-remove \
+      git-mr-sendmail \
+      git-mr-show \
+      git-mr-tag \
+      git-mr-update \
+      mrlib.py
+
+MAN1_TXT_FILES = $(DOCDIR)/git-mr-show.txt \
+                $(DOCDIR)/git-mr-create.txt \
+                $(DOCDIR)/git-mr-tag.txt \
+                $(DOCDIR)/git-mr-fetch.txt \
+                $(DOCDIR)/git-mr-push.txt \
+                $(DOCDIR)/git-mr-remove.txt \
+                $(DOCDIR)/git-mr-sendmail.txt \
+                $(DOCDIR)/git-mr-update.txt \
+                $(DOCDIR)/git-mr-mbox.txt \
+                $(DOCDIR)/git-mr.txt
+
+MAN1_FILES = $(patsubst $(DOCDIR)/%.txt, $(MANDIR)/%.1, $(MAN1_TXT_FILES))
+HTML_FILES = $(patsubst $(DOCDIR)/%.txt, $(HTMLDIR)/%.html, $(MAN1_TXT_FILES))
+
+$(MANDIR)/%.1: $(DOCDIR)/%.txt
+       a2x --destination-dir=$(MANDIR) --doctype manpage --format manpage $<
+
+$(HTMLDIR)/%.html: $(DOCDIR)/%.txt
+       a2x --destination-dir=$(HTMLDIR) --doctype manpage --format xhtml $<
+
+$(MANDIR) $(HTMLDIR):
+       mkdir -p $@
+
+$(MAN1_FILES): | $(MANDIR)
+$(HTML_FILES): | $(HTMLDIR)
+
+man: $(MAN1_FILES) 
+
+html: $(HTML_FILES)
+
+clean_man:
+       $(RM) -r $(MANDIR)
+
+clean_html:
+       $(RM) -r $(HTMLDIR)
+
+RPM_TOP_DIR = $(CURDIR)/rpmbuild
+rpm:
+       mkdir -p $(RPM_TOP_DIR)/{BUILD,RPMS,SPECS,SRPMS}
+       $(RPMBUILD) -bb --build-in-place --define "_topdir $(RPM_TOP_DIR)" gimrr.spec
+
+doc: man html
+
+clean: clean_man clean_html
+       $(RM) -r $(RPM_TOP_DIR)
+
+install: $(BIN_FILES) $(MAN1_FILES)
+       $(INSTALL) -d -m 755 $(DESTDIR)$(bindir)
+       $(INSTALL) -m 755 $(BIN_FILES) $(DESTDIR)$(bindir)
+       $(INSTALL) -d -m 755 $(DESTDIR)$(man1dir)
+       $(INSTALL) -m 644 $(MAN1_FILES) $(DESTDIR)$(man1dir)
+
+uninstall:
+       $(RM) $(patsubst %, $(DESTDIR)$(bindir)/%, $(notdir $(BIN_FILES)))
+       $(RM) $(patsubst %, $(DESTDIR)$(man1dir)/%, $(notdir $(MAN1_FILES)))
diff --git a/gimrr.spec b/gimrr.spec
new file mode 100644 (file)
index 0000000..5a2c596
--- /dev/null
@@ -0,0 +1,28 @@
+Name:       gimrr
+Version:    0
+Release:    1
+Summary:    Set of simple tools to manage merge requests and their review within git
+License:    WTFPL
+BuildArch:  noarch
+BuildRequires: asciidoc
+Requires: python3 >= 3.2
+Requires: git
+
+%description
+Dumb, buggy, incomplete and especially NOT modern tool for old farts without WebUI.
+
+%prep
+
+%build
+make man
+
+%install
+make install DESTDIR=%{buildroot} bindir=%{_bindir} mandir=%{_mandir}
+
+%files
+%defattr(-,root,root)
+%{_bindir}/*
+%{_mandir}/man1/*
+%license COPYING
+
+%changelog
diff --git a/git-mr-create b/git-mr-create
new file mode 100755 (executable)
index 0000000..4f23434
--- /dev/null
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import tempfile
+import sys
+import mrlib
+import time
+
+init_desc="""
+
+# Please enter the MR commit message for your changes. Lines starting
+# with '#' will be ignored, and an empty message aborts the commit.
+"""
+
+parser = argparse.ArgumentParser(description="Create new merge request.");
+parser.add_argument("mr", nargs="?", default="HEAD", metavar="REV",
+        help="New merge request revision (default: HEAD)")
+parser.add_argument("-", "--stdin", action="store_true",
+        help="Read MR description from stdin. Description entered via -m or -F option is ignored.")
+parser.add_argument("-F", "--file", metavar="FILE",
+        help="Use MR description from FILE. Description entered via -m option is ignored.")
+parser.add_argument("-m", "--message", nargs="?", metavar="MSG", action="append",
+        help="Use MSG as MR description. Multiple MSGs are concatenated as separate paragraphs.")
+parser.add_argument("-b", "--base", metavar="REV", required=True,
+        help="Base revision of the merge request.")
+
+args = parser.parse_args()
+
+mr = mrlib.verify_rev(args.mr)
+base = mrlib.verify_rev(args.base)
+
+if mr == base:
+    sys.exit("No new commits.")
+
+user = mrlib.get_user()
+tmpdir = tempfile.TemporaryDirectory()
+
+tags = dict()
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "B", user, base)
+
+if args.stdin:
+    desc = sys.stdin.read()
+elif args.file:
+    with open(args.file, "r") as f:
+        desc = f.read()
+elif args.message:
+    desc = "\n\n".join(args.message)
+else:
+    desc = mrlib.editor(init_desc)
+
+desc = desc.strip()
+
+split = desc.split("\n\n", 1)
+subj = split[0].replace("\n", " ")
+subj = subj.strip()
+if not subj:
+    sys.exit("Empty MR commit message. Aborting...")
+
+desc = ""
+if len(split) > 1:
+    desc = "".join(split[1:])
+    desc = desc.replace("\n", "\\n")
+
+an = mrlib.git_config("user.name")
+if not an:
+    sys.exit("No git user.name found.")
+
+ae = mrlib.git_config("user.email")
+if not ae:
+    sys.exit("No git user.email found.")
+at = int(time.time())
+
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "AN", user, an)
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "AE", user, ae)
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "AT", user, at)
+
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "S", user, subj)
+if desc:
+    mrlib.add_tag(tags, mr, mrlib.rev_zero, "D", user, desc)
+
+fmt = "--format=format:%H s %s%n%H an %an%n%H ae %ae%n%H at %at"
+rv, out, err = mrlib.run(["git", "log", fmt, "{}..{}".format(base, mr)])
+
+commits = list()
+for line in out.split("\n"):
+    commit,tag,value= line.split(None, 2)
+
+    if commit not in commits:
+        commits.insert(0, commit)
+
+    mrlib.add_tag(tags, mr, commit, tag, user, value)
+
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "CC", user, str(len(commits)))
+mrlib.add_tag(tags, mr, mrlib.rev_zero, "C", user, " ".join(commits))
+
+tagsfn = "{}/tags".format(tmpdir.name)
+with open(tagsfn, 'w+') as f:
+    mrlib.store_user_tags(tags, mr, user, f)
+
+tagssha = mrlib.commit_file(tagsfn, "tags", "Initial tags for MR {}".format(mr))
+
+mrlib.update_ref("refs/mrs/{}/{}/head".format(mr, user), mr, oldrev=mrlib.rev_zero)
+mrlib.update_ref("refs/mrs/{}/{}/tags".format(mr, user), tagssha, oldrev=mrlib.rev_zero)
diff --git a/git-mr-fetch b/git-mr-fetch
new file mode 100755 (executable)
index 0000000..c5b13ee
--- /dev/null
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import sys
+import mrlib
+
+parser = argparse.ArgumentParser(description="Download merge request(s), tags and mboxes from remote.");
+parser.add_argument("mr", nargs="*", default=["HEAD"], metavar="MR",
+        help="MR revision (default: HEAD).")
+parser.add_argument("-A", "--all", action='store_true',
+        help="Fetch all refs from remote. MRs entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-", "--stdin", action='store_true',
+        help="Read MRs from stdin. MRs entered as positional arguments are ignored.")
+parser.add_argument("-r", "--remote", metavar="REPOSITORY", default="origin",
+        help="The remote repository to fetch from (default: origin).")
+parser.add_argument("--head", action='store_true',
+        help="Fetch MR commits.")
+parser.add_argument("--tags", action='store_true',
+        help="Fetch tags.")
+parser.add_argument("--mbox", action='store_true',
+        help="Fetch mboxes.")
+parser.add_argument("-f", "--force", action='store_true',
+        help="Fetch also refs for current user. All local changes will be lost.")
+parser.add_argument("-q", "--quiet", action='store_true',
+        help="Progress is not reported to the standard error stream.")
+
+args = parser.parse_args()
+user = mrlib.get_user()
+currefs = mrlib.load_refs()
+types = []
+
+if args.all:
+    mrs = []
+elif args.stdin:
+    mrs = mrlib.verify_mrs(sys.stdin.readlines(), currefs)
+else:
+    mrs = mrlib.verify_mrs(args.mr, currefs)
+
+# In future switch to negative refs introduced in git 2.28
+rv, out, err = mrlib.run(["git", "ls-remote", args.remote, "refs/mrs/*"])
+
+remote_refs = mrlib.load_refs(buf=out)
+newrefs = mrlib.new_refs(currefs, remote_refs)
+if not newrefs:
+    sys.exit()
+
+if args.head:
+    types += ["head"]
+
+if args.tags:
+    types += ["tags"]
+
+if args.mbox:
+    types += ["mbox"]
+
+if not types:
+    types = ["head", "mbox", "tags"]
+
+if args.force:
+    newrefs = mrlib.filter_refs(newrefs, mrs=mrs, types=types)
+else:
+    newrefs = mrlib.filter_refs(newrefs, mrs=mrs, not_users=[user], types=types)
+
+newrefs = ["{}:{}".format(ref, ref) for rev, ref in newrefs]
+
+if not newrefs:
+    sys.exit()
+
+cmd = ["git", "fetch"]
+if args.quiet:
+    cmd += ["--quiet"]
+
+cmd += [args.remote] + newrefs
+
+rv, out, err = mrlib.run(cmd)
+if err:
+    print(err, end="")
diff --git a/git-mr-mbox b/git-mr-mbox
new file mode 100755 (executable)
index 0000000..8feb15d
--- /dev/null
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import subprocess
+import argparse
+import sys
+import mrlib
+import fcntl
+import os
+import tempfile
+
+parser = argparse.ArgumentParser(description="Generate mbox for merge request(s) and print it to stdout.");
+parser.add_argument("mr", nargs="*", default=["HEAD"], metavar="MR",
+        help="MR revision (default: HEAD)")
+parser.add_argument("-A", "--all", action='store_true',
+        help="Use all available MRs as input. MRs entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-", "--stdin", action='store_true',
+        help="Read MRs from stdin. MRs entered as positional arguments are ignored.")
+parser.add_argument("-r", "--read-refs", metavar="FILE",
+        help="Read previously stored refs from FILE and use them as a start point for mbox generation. If not specified, complete MRs mbox will be generated.")
+parser.add_argument("-w", "--write-refs", metavar="FILE",
+        help="Write updated refs to FILE, which may be later used as input for the -r option. Using both -r and -w option with the same FILE, along with the -m option, will incrementally update mbox with new messages only.")
+parser.add_argument("-m", "--mbox", metavar="FILE",
+        help="Append generated mbox to FILE instead of printing it to stdout. If FILE doesn't exist, it's created.")
+
+args = parser.parse_args()
+
+currefs = mrlib.load_refs()
+currefs = mrlib.filter_refs(currefs)
+newrefs = []
+oldrefs = []
+
+if args.read_refs and os.path.exists(args.read_refs):
+    rfd = open(args.read_refs, "r")
+    fcntl.lockf(rfd.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
+    oldrefs = mrlib.load_refs(fd=rfd)
+
+if not mrlib.new_refs(oldrefs, currefs, types=["head", "mbox"]):
+    sys.exit()
+
+if args.write_refs:
+    wfd_old = open(args.write_refs, "a+")
+    fcntl.lockf(wfd_old.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+    wfd_old.seek(0)
+
+if args.mbox:
+    mfd = open(args.mbox, "ab+")
+    fcntl.lockf(mfd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+if args.all:
+    mrs = mrlib.filter_revs(currefs, types=["head"])
+elif args.stdin:
+    mrs = mrlib.verify_mrs(sys.stdin.readlines(), currefs)
+else:
+    mrs = mrlib.verify_mrs(args.mr, currefs)
+
+if args.write_refs:
+    wdir = tempfile.TemporaryDirectory(dir=os.path.dirname(os.path.realpath(args.write_refs)))
+    wfn_new = "{}/write_refs".format(wdir.name)
+    wfd_new = open(wfn_new, "w")
+
+if args.mbox:
+    try:
+        pos = mfd.tell()
+        mrlib.store_mbox(mrs, oldrefs, currefs, newrefs, mfd)
+        mfd.flush()
+    except:
+        mfd.truncate(pos)
+        raise
+else:
+    mrlib.store_mbox(mrs, oldrefs, currefs, newrefs, sys.stdout.buffer)
+
+if args.write_refs:
+    mrlib.store_refs(newrefs, wfd_new)
+    wfd_new.close()
+    os.rename(wfn_new, args.write_refs)
diff --git a/git-mr-push b/git-mr-push
new file mode 100755 (executable)
index 0000000..f2b8e18
--- /dev/null
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import sys
+import mrlib
+
+parser = argparse.ArgumentParser(description="Update remote merge request(s), tags and mboxes.");
+parser.add_argument("mr", nargs="*", default=["HEAD"], metavar="MR",
+        help="MR revision (default: HEAD)")
+parser.add_argument("-A", "--all", action='store_true',
+        help="Use all available MRs as input. MRs entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-", "--stdin", action='store_true',
+        help="Read MRs from stdin. MRs entered as positional arguments are ignored.")
+parser.add_argument("-r", "--remote", metavar="REPOSITORY", default="origin",
+        help="The remote repository that is destination of a push operation (default: origin).")
+parser.add_argument("--head", action='store_true',
+        help="Push MR commits.")
+parser.add_argument("--tags", action='store_true',
+        help="Push tags.")
+parser.add_argument("--mbox", action='store_true',
+        help="Push mboxes.")
+parser.add_argument("-q", "--quiet", action='store_true',
+        help="Progress is not reported to the standard error stream.")
+
+args = parser.parse_args()
+user = mrlib.get_user()
+refs = mrlib.load_refs()
+push_refs = []
+types = []
+
+if args.head:
+    types += ["head"]
+
+if args.tags:
+    types += ["tags"]
+
+if args.mbox:
+    types += ["mbox"]
+
+if not types:
+    types = ["head", "mbox", "tags"]
+
+if args.all:
+    mrs = mrlib.filter_revs(refs, types=["head"], users=[user])
+else:
+    if args.stdin:
+        mrs = mrlib.verify_mrs(sys.stdin.readlines(), refs)
+    else:
+        mrs = mrlib.verify_mrs(args.mr, refs)
+
+# push the heads first
+for type in types:
+    push_refs += mrlib.filter_refs(refs, mrs, users=[user], types=[type])
+
+push_refs = ["{}:{}".format(ref, ref) for rev, ref in push_refs]
+
+if not push_refs:
+    sys.exit()
+
+cmd = ["git", "push"]
+if args.quiet:
+    cmd += ["--quiet"]
+
+cmd += [args.remote] + push_refs
+
+rv, out, err = mrlib.run(cmd)
+if err:
+    print(err, end="")
diff --git a/git-mr-remove b/git-mr-remove
new file mode 100755 (executable)
index 0000000..abd8cd1
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import mrlib
+import sys
+
+parser = argparse.ArgumentParser(description="Remove merge request(s), tags and mboxes");
+parser.add_argument("mr", nargs="*", default=["HEAD"], metavar="MR",
+        help="Merge request revisions (default: HEAD)")
+parser.add_argument("-A", "--all", action="store_true",
+        help="Use all available MRs as input. MRs entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-", "--stdin", action="store_true",
+        help="Read MRs from stdin. MRs entered as positional arguments are ignored.")
+parser.add_argument("--head", action="store_true",
+        help="Remove MR commits.")
+parser.add_argument("--tags", action="store_true",
+        help="Remove tags.")
+parser.add_argument("--mbox", action="store_true",
+        help="Remove mboxes.")
+parser.add_argument("-u", "--user", nargs="?", metavar="USER", action="append",
+        help="Remove refs for specific user only. This option can be used multiple times.")
+
+args = parser.parse_args()
+user = mrlib.get_user()
+refs = mrlib.load_refs()
+types = list()
+
+if args.head:
+    types += ["head"]
+
+if args.tags:
+    types += ["tags"]
+
+if args.mbox:
+    types += ["mbox"]
+
+if not types:
+    types = ["head", "mbox", "tags"]
+
+if args.user and args.user[0] == None:
+    args.user = [user]
+
+if args.all:
+    mrs = mrlib.filter_revs(refs, types=["head"])
+elif args.stdin:
+    mrs = mrlib.verify_mrs(sys.stdin.readlines(), refs)
+else:
+    mrs = mrlib.verify_mrs(args.mr, refs)
+
+refs = mrlib.filter_refs(refs, mrs=mrs, types=types, users=args.user)
+
+if not refs:
+    sys.exit()
+
+for rev, ref in refs:
+    mrlib.update_ref(ref, delete=True)
diff --git a/git-mr-sendmail b/git-mr-sendmail
new file mode 100755 (executable)
index 0000000..e60fa18
--- /dev/null
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import subprocess
+import argparse
+import tempfile
+import sys
+import mrlib
+import re
+
+def get_hdr_value(hdrs, name):
+    for hdr in hdrs:
+        h = hdr.split(':', 1)
+        if len(h) != 2:
+            continue
+        n, v = h
+        if n.lower() == name.lower():
+            return v.strip()
+    return ""
+
+tmpdir = tempfile.TemporaryDirectory()
+
+parser = argparse.ArgumentParser(description="Simulate sendmail behafiour to send reply to MR, commit or comment.");
+parser.add_argument("recipient", nargs="*", metavar="RECIPIENT",
+        help="Positional argumets are ignored. This is for sendmail compatibility only.")
+parser.add_argument("-f", "--file", metavar="FILE",
+        help="Read reply fro FILE instead from stdin.")
+
+args=parser.parse_args()
+
+user = mrlib.get_user()
+
+hdrs = []
+body = ""
+
+if args.file:
+    fd = open(args.file, "r")
+else:
+    fd = sys.stdin
+
+mail = fd.read().split("\n\n", 1)
+if len(mail) > 1:
+    body = mail[1]
+
+for i, hdr in enumerate(mail[0].split("\n")):
+    if hdr.startswith((' ', '\t')):
+        hdrs[-1] += " " + hdr.lstrip()
+    else:
+        hdrs.append(hdr)
+
+inreplyto = get_hdr_value(hdrs, "in-reply-to")
+if not inreplyto:
+    sys.exit("In-Reply-To header not found.")
+
+match = re.match("<([0-9a-f]{40})", inreplyto)
+if not match:
+    sys.exit("Cannot find MR in In-Reply-To header.")
+
+mr = match[1]
+refs = mrlib.load_refs()
+mr = mrlib.verify_mrs([mr], refs)[0]
+
+subj = get_hdr_value(hdrs, "subject")
+if not subj:
+    sys.exit("Subject header not found.")
+
+to = get_hdr_value(hdrs, "to")
+if not subj:
+    sys.exit("To header not found.")
+
+mboxfn = "{}/mbox".format(tmpdir.name)
+
+with open(mboxfn, mode="w") as f:
+    f.write("{}\n\nTo: {}\nIn-Reply-To: {}\n\n".format(subj, to, inreplyto))
+    f.write(body)
+
+with open(mboxfn, mode="r") as f2:
+    print(f2.read())
+
+oldref = mrlib.filter_revs(refs, mrs=[mr], users=[user], types=["mbox"])
+
+if oldref:
+    oldrev = oldref[0]
+else:
+    oldrev = None
+
+mboxsha = mrlib.commit_file(None, None, None, oldrev, mboxfn)
+
+if not oldrev:
+    oldrev = mrlib.rev_zero
+
+mrlib.update_ref("refs/mrs/{}/{}/mbox".format(mr, user), mboxsha, oldrev=oldrev)
diff --git a/git-mr-show b/git-mr-show
new file mode 100755 (executable)
index 0000000..7318354
--- /dev/null
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import string
+import sys
+import subprocess
+import mrlib
+
+mfmt = "{M:.16} {B:.16} {CC:6.6} {S:60.60} {AN:16.16} {AE:>20.20} {AD}"
+cfmt = "   {h:.16} {s}"
+parser = argparse.ArgumentParser(description="Show information about merge request(s) and its commits.")
+parser.add_argument("mr", nargs="*", default=["HEAD"], metavar="REV",
+        help="MR revision(s) for which to show information (default: HEAD).")
+parser.add_argument("-m", "--my", action="store_true",
+        help="Show information for MRs created by me. MRs entered as positional arguments, on stdin or with --all option are ignored.")
+parser.add_argument("-A", "--all", action="store_true",
+        help="Show information for all MRs. MRs entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-", "--stdin", action="store_true",
+        help="Read MRs from stdin. MRs entered as positional arguments are ignored.")
+parser.add_argument("-c", "--commits", action="store_true",
+        help="Show also commits information for each MR.")
+parser.add_argument("-s", "--squash", action="store_true",
+        help="Squash commit tags into MR.")
+parser.add_argument("--mfmt", metavar="FMT", default=mfmt,
+        help="Python's formatted string for MRs.")
+parser.add_argument("--cfmt", metavar="FMT", default=cfmt,
+        help="Python's formatted string for MR comments.")
+parser.add_argument("-t", "--tags", action="store_true",
+        help="List available tags for selected MRs.")
+parser.add_argument("-u", "--user", nargs="?", metavar="USER", action="append",
+        help="Show tags for specified user(s) only. Can be used multiple times.")
+
+args = parser.parse_args()
+user = mrlib.get_user()
+
+class TagFormatter(string.Formatter):
+    def __init__(self):
+        pass
+
+    def get_field(self, field, a, ctags):
+        f = field.split("@", 3)
+        c = len(f)
+
+        name = f[0]
+        fmt = "v"
+        users = None
+        sep = " "
+
+        if c == 4:
+            fmt = f[1]
+            sep = f[2]
+            users = f[3]
+        elif c == 3:
+            fmt = f[1]
+            sep = f[2]
+        elif c == 2:
+            fmt = f[1]
+
+        if not fmt:
+            fmt = "v"
+        if not sep:
+            sep = " "
+        if users:
+            users = users.split(",")
+        elif args.user:
+            if args.user[0] == None:
+                users = [user]
+            else:
+                users = args.user
+
+        tag = mrlib.get_tag(ctags, name)
+        if not tag:
+            return ("{{{}}}".format(field), None)
+
+        value = None
+
+        if fmt == "v":
+            values = mrlib.get_tag_values(ctags, name, users)
+            if values:
+                value = sep.join(values)
+
+        elif fmt == "c":
+            values = mrlib.get_tag_values(ctags, name, users)
+            if values:
+                value = str(len(values))
+
+        elif fmt == "r":
+            value = sep.join("{}:{}".format(u, v) for u, v in tag)
+
+        else:
+            sys.exit("Unknown format.")
+
+        if not value:
+            value = "{{{}}}".format(field)
+
+        return (value, field)
+
+tfmt = TagFormatter()
+def printt(fmt, ctags):
+    print(tfmt.vformat(fmt, None, ctags))
+
+refs = mrlib.load_refs()
+
+if args.my:
+    mrs = mrlib.filter_revs(refs, types=["head"], users=[user])
+elif args.all:
+    mrs = mrlib.filter_revs(refs, types=["head"])
+else:
+    if args.stdin:
+        mrs = sys.stdin.readlines()
+    else:
+        mrs = args.mr
+
+    mrs = mrlib.verify_mrs(mrs, refs)
+
+tags = mrlib.load_tags(mrs, refs=refs, commits=args.commits, squash=args.squash)
+
+mrs_sorted = list()
+for mr in mrs:
+    ctags = mrlib.get_commit_tags(tags, mr, mrlib.rev_zero)
+    if not ctags:
+        continue
+
+    dt = mrlib.get_tag_value(ctags, "AT")
+    mrs_sorted += [(mr,dt)]
+
+mrs_sorted = sorted(mrs_sorted, key=lambda mr: mr[1])
+
+tag_desc = {
+        "M": "MR sha",
+        "A": "MR author",
+        "S": "MR or commit subject",
+        "D": "MR description",
+        "B": "MR base sha",
+        "C": "MR commit list",
+        "CC": "MR commit count",
+        "AN": "MR author name",
+        "AE": "MR author email",
+        "AT": "MR author date, UNIX timestamp",
+        "AI": "MR author date, ISO 8601-like format",
+        "AD": "MR author date",
+        "h": "commit sha",
+        "an": "author name",
+        "ae": "author email",
+        "at": "author date, UNIX timestamp",
+        "ai": "author date, ISO 8601-like format",
+        "ad": "author date",
+        }
+
+if args.tags:
+    tset = {"M", "h"}
+    for commits in tags.values():
+        for tags in commits.values():
+            for tag in tags:
+                tset.add(tag)
+    for t in tset:
+        if t in tag_desc:
+            print("{:10.10} {}".format(t, tag_desc[t]))
+        else:
+            print("{:10.10} description not available".format(t))
+    sys.exit()
+
+for mr, dt in mrs_sorted:
+    mrtags = mrlib.get_commit_tags(tags, mr, mrlib.rev_zero)
+    if not mrtags:
+        continue
+
+    mrtags["M"] = {("", mr)}
+
+    printt(args.mfmt, mrtags)
+
+    if args.commits:
+        commits = mrlib.get_tag_value(mrtags, "C")
+        if not commits:
+            continue
+
+        for commit in commits.split():
+            ctags = mrlib.get_commit_tags(tags, mr, commit)
+            if not ctags:
+                continue
+            ctags["M"] = {("", mr)}
+            ctags["h"] = {("", commit)}
+
+            printt(args.cfmt, ctags)
diff --git a/git-mr-tag b/git-mr-tag
new file mode 100755 (executable)
index 0000000..41d44d6
--- /dev/null
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import subprocess
+import argparse
+import tempfile
+import sys
+import mrlib
+
+parser = argparse.ArgumentParser(description="Add/remove tag for merge request or commits.");
+parser.add_argument("commit", nargs="*", default=[mrlib.rev_zero], metavar="COMMIT",
+        help="Commit(s) to which the tag will be added or moreved. (default: MR commit)")
+parser.add_argument("-", "--stdin", action="store_true",
+        help="Read commit(s) from stdin. Commit(s) entered as positional arguments are ignored.")
+parser.add_argument("-m", "--mr", metavar="MR", default="HEAD",
+        help="Merge request containing commit(s) (default: HEAD).")
+parser.add_argument("-a", "--add", action="store_true",
+        help="Add tag.")
+parser.add_argument("-r", "--remove", action="store_true",
+        help="Remove tag.")
+parser.add_argument("-A", "--all", action="store_true",
+        help="Add tag to all commit(s) in merge request. Commit(s) entered as positional arguments or on stdin are ignored.")
+parser.add_argument("-t", "--tag", required=True, metavar="TAG",
+        help="Tag name.")
+parser.add_argument("-v", "--value", metavar="VALUE",
+        help="Tag value.")
+
+args = parser.parse_args()
+
+if not args.add and not args.remove:
+    sys.exit("Either add or remove operation has to be specified.")
+
+if args.add and not args.value:
+    sys.exit("Adding tag requires value.")
+
+value = None
+if args.value:
+    value = args.value.strip()
+    if not value:
+        sys.exit("Empty value.")
+
+tag = args.tag.strip()
+if not tag.isalnum():
+    sys.exit("Invalid tag name.")
+
+user = mrlib.get_user()
+refs = mrlib.load_refs()
+mr = mrlib.verify_mrs([args.mr], refs)[0]
+tags = mrlib.load_tags([mr], refs=refs, commits=False)
+
+mrtags = mrlib.get_commit_tags(tags, mr, mrlib.rev_zero)
+if not mrtags:
+    sys.exit("No MR tags found.")
+
+mrcommits = mrlib.get_tag_value(mrtags, "C")
+if not mrcommits:
+    sys.exit("No MR commits found.")
+
+mrcommits = mrcommits.split()
+
+if args.all:
+    commits = mrcommits
+elif args.stdin:
+    commits = mrlib.verify_commits(mrcommits, sys.stdin.readlines())
+else:
+    commits = mrlib.verify_commits(mrcommits, args.commit)
+
+tags = mrlib.load_tags([mr], refs=refs, users=[user])
+
+if args.add:
+    for commit in commits:
+        mrlib.add_tag(tags, mr, commit, tag, user, value)
+
+elif args.remove:
+    for commit in commits:
+        mrlib.remove_tag(tags, mr, commit, tag, user, value)
+
+tmpdir = tempfile.TemporaryDirectory()
+tagsfn = "{}/tags".format(tmpdir.name)
+mr = mrlib.verify_rev(args.mr)
+
+with open(tagsfn, 'w+') as f:
+    mrlib.store_user_tags(tags, mr, user, f)
+
+oldrev = mrlib.filter_revs(refs, mrs=[mr], users=[user], types=["tags"])
+if not oldrev:
+    oldrev = mrlib.rev_zero
+    parent = None
+else:
+    oldrev = oldrev[0]
+    parent = oldrev
+
+tagssha = mrlib.commit_file(tagsfn, "tags", "Updated tags for MR {}".format(mr), parent)
+
+mrlib.update_ref("refs/mrs/{}/{}/tags".format(mr, user), tagssha, oldrev=oldrev)
diff --git a/git-mr-update b/git-mr-update
new file mode 100755 (executable)
index 0000000..f2a0971
--- /dev/null
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import mrlib
+import sys
+import os
+import re
+
+argc = len(sys.argv)
+
+if argc != 4:
+    sys.exit("usage: {} <ref> <oldrev> <newrev>".format(sys.argv[0]))
+
+refname = sys.argv[1]
+oldrev = sys.argv[2]
+newrev = sys.argv[3]
+
+admins = mrlib.git_config("mr.admins")
+if not admins:
+    sys.exit("No git-mr admins found.")
+
+admins = admins.split()
+
+user = os.environ.get("GIMRR_USER")
+if not user:
+    sys.exit("No git-mr user found.")
+
+if user in admins:
+    sys.exit()
+
+if newrev == mrlib.rev_zero:
+    sys.exit("You cannot delete ref '{}'.".format(refname))
+
+match = re.match("^refs/mrs/([0-9a-f]{40})/([^/]+)/(head|tags|mbox)$", refname)
+if not match:
+    sys.exit("You cannot push ref '{}'.".format(refname))
+
+ref_mr = match.group(1)
+ref_user = match.group(2)
+ref_type = match.group(3)
+
+if ref_user != user:
+    sys.exit("User '{}' in ref '{}' does not match user '{}' on server.".format(ref_user, refname, user))
+
+if ref_type not in ["head", "tags", "mbox"]:
+    sys.exit("Invalid ref type '{}' in '{}'. Allowed types are 'head', 'tags' or 'mbox'.".format(ref_type, refname))
+
+if oldrev != mrlib.rev_zero:
+    rv, out, err = mrlib.run(["git", "merge-base", "--is-ancestor", oldrev, newrev], die=False)
+    if rv:
+        sys.exit("Non-fast-forward update of ref '{}'.".format(refname))
+
+if ref_type == "head":
+    if oldrev != mrlib.rev_zero:
+        sys.exit("You are not allowed to update MR head for ref '{}'.".format(refname))
+
+    if newrev != ref_mr:
+        sys.exit("MR head rev '{}' does not match MR '{}' in '{}'.".format(newrev, ref_mr, refname))
+
+    # This is racy, but better this than nothing. If two users, by any chance,
+    # push the same MR, it will be visible and can be dealt with.
+    rv, out, err = mrlib.run(["git", "for-each-ref", "refs/mrs/{}/*/head".format(ref_mr)])
+    out = out.strip()
+    if out:
+        sys.exit("MR head '{}' already exists as '{}'.".format(ref_mr, out))
+
+elif ref_type == "tags":
+    rv, out, err = mrlib.run(["git", "for-each-ref", "--format=%(refname)",
+        "refs/mrs/{}/*/head".format(ref_mr)])
+    if not out:
+        sys.exit("No MR '{}' exists on server.".format(ref_mr))
+
+    match = re.match("^refs/mrs/{}/([^/]+)/head$".format(ref_mr), out)
+    mr_owner = match.group(1)
+
+    rv, out, err = mrlib.run(["git", "show", "{}:tags".format(newrev)])
+    tags = mrlib.load_user_tags(out)
+
+    mr_tags = mrlib.get_commit_tags(tags, ref_mr, mrlib.rev_zero)
+    if not mr_tags:
+        sys.exit()
+
+    if user != mr_owner:
+        sys.exit("MR tags can be set by owners and admins only.")
+
+elif ref_type == "mbox":
+    stdin = "{}\n".format(newrev)
+    if oldrev != mrlib.rev_zero:
+        stdin += "^{}\n".format(oldrev)
+
+    revs = list()
+    rv, out, err = mrlib.run(["git", "rev-list", "--stdin"], stdin=stdin)
+    for line in out.split("\n"):
+        if not line:
+            continue
+        revs.append(line)
+
+    for rev in revs:
+        rv, out, err = mrlib.run(["git", "show", "--format=%b", rev])
+        lines = out.split("\n")
+        if len(lines) < 3:
+            sys.exit("Invalid comment format. Expecting at least three lines.")
+
+        if not lines[0].lower().startswith("to: "):
+            sys.exit("Expecting 1st line '{}' to be 'To' header.".format(lines[0]))
+
+        if not lines[1].lower().startswith("in-reply-to: "):
+            sys.exit("Expecting 2nd line '{}' to be 'In-Reply-To' header.".format(lines[1]))
+
+        if lines[2]:
+            sys.exit("Expecting 3rd line '{}' to be empty.".format(lines[2]))
+
+sys.exit()
diff --git a/mrlib.py b/mrlib.py
new file mode 100755 (executable)
index 0000000..8869874
--- /dev/null
+++ b/mrlib.py
@@ -0,0 +1,499 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import subprocess
+import sys
+import tempfile
+import re
+import fcntl
+import datetime
+
+from_line = "From localhost.localdomain Wed Sep  1 08:00:00 CET 2010"
+rev_zero = "0"*40
+
+def run(cmd, stdin=None, stdout=True, stderr=True, text=True, env=None, die=True):
+    if stdin and text:
+        stdin = stdin.encode()
+
+    p = subprocess.Popen(cmd,
+            stdin=subprocess.PIPE if stdin else None,
+            stdout=subprocess.PIPE if stdout else None,
+            stderr=subprocess.PIPE if stderr else None,
+            env=env)
+
+    out, err = p.communicate(input=stdin)
+    if not stdout:
+        out = b""
+    if not stderr:
+        err = b""
+
+    if text:
+        out = out.decode()
+        err = err.decode()
+
+    if die and p.returncode:
+        if not text:
+            err = err.decode()
+
+        sys.exit(err)
+
+    return (p.returncode, out, err)
+
+def new_refs(oldrefs, currefs, types=None):
+    if types:
+        oldrefs = filter_refs(oldrefs, types=types)
+        currefs = filter_refs(currefs, types=types)
+    old = set(oldrefs)
+    cur = set(currefs)
+    new = cur - old
+    return list(new)
+
+def load_refs(fd=None, buf=None):
+    refs = list()
+
+    if buf != None:
+        out = buf
+    elif fd != None:
+        out = fd.read()
+    else:
+        rv, out, err = run(["git", "for-each-ref",
+            "--format=%(objectname) %(refname)", "refs/mrs/*/*/*"])
+
+    out = out.rstrip()
+    if not out:
+        return []
+
+    for line in out.split("\n"):
+        rev, ref = line.split()
+        refs.append((rev, ref))
+
+    return refs
+
+def store_refs(refs, fd):
+    for rev, ref in refs:
+        fd.write("{} {}\n".format(rev, ref))
+
+def filter_refs(refs_in, mrs=None, users=None, types=None,
+        not_mrs=None, not_users=None, not_types=None):
+
+    refs = list()
+    for rev, ref in refs_in:
+        refs_dir, mrs_dir, mr, user, type = ref.split("/")
+
+        if not_mrs and mr in not_mrs:
+            continue
+
+        if not_users and user in not_users:
+            continue
+
+        if not_types and type in not_types:
+            continue
+
+        if mrs and mr not in mrs:
+            continue
+
+        if users and user not in users:
+            continue
+
+        if types and type not in types:
+            continue
+
+        refs.append((rev, ref))
+
+    return refs
+
+def filter_revs(refs_in, mrs=None, users=None, types=None,
+        not_mrs=None, not_users=None, not_types=None):
+    refs = filter_refs(refs_in, mrs, users, types,
+            not_mrs, not_users, not_types)
+    return [rev for rev, ref in refs]
+
+def verify_rev(rev):
+    rev = rev.strip()
+    rv, out, err = run(["git", "rev-parse", "--verify", rev])
+    if rv:
+            sys.exit("Invalid rev: {}".format(rev))
+
+    return out.rstrip()
+
+def verify_mrs(mrs, refs):
+    revs = set()
+    for mr in mrs:
+        rev = verify_rev(mr)
+        revs.add(rev)
+
+    valid = set(filter_revs(refs, types=["head"]))
+    diff = revs - valid
+    if diff:
+        sys.exit("Invalid or non-existing MR: {}".format(" ".join(diff)))
+
+    return list(revs)
+
+def verify_commits(mrcommits, commits):
+    mrc = set(mrcommits)
+    mrc.add(rev_zero)
+    c = set(commits)
+    diff = c - mrc
+    if diff:
+        sys.exit("Invalid commits: {}".format(" ".join(diff)))
+
+    return commits
+
+
+def add_tag(tags, mr, commit, tag, user, value):
+    if mr not in tags:
+        tags[mr] = dict()
+
+    if commit not in tags[mr]:
+        tags[mr][commit] = dict()
+
+    if tag not in tags[mr][commit]:
+        tags[mr][commit][tag] = set()
+
+    tags[mr][commit][tag].add((user, value))
+
+def remove_tag(tags, mr, commit, tag, user=None, value=None):
+    if mr not in tags:
+        return
+
+    if not commit in tags[mr]:
+        return
+
+    if not tag in tags[mr][commit]:
+        return
+
+    if not user and not value:
+        del tags[mr][commit][tag]
+        return
+
+    if user:
+        tags[mr][commit][tag] = {(u,v) for u,v in tags[mr][commit][tag] if u != user}
+
+    if value:
+        tags[mr][commit][tag] = {(u,v) for u,v in tags[mr][commit][tag] if v != value}
+
+    if not tags[mr][commit][tag]:
+        del tags[mr][commit][tag]
+
+def load_tags(mrs, refs=None, users=None, commits=True, squash=False):
+    tags = dict()
+
+    if not refs:
+        refs = load_refs()
+
+    revs = filter_revs(refs, mrs=mrs, users=users, types=["tags"])
+    if not revs:
+        return tags
+
+    stdin = ""
+    for rev in revs:
+        stdin += "{}:tags\n".format(rev)
+
+    rv, out, err = run(["git", "cat-file", "--batch"], stdin=stdin)
+
+    mr = None
+    user = None
+    commit = None
+    for line in out.split("\n"):
+        if not line:
+            continue
+
+        tag, value = line.split(None, 1)
+        if tag == "@m":
+            mr = value
+            continue
+
+        if tag == "@u":
+            user = value
+            continue
+
+        if tag == "@c":
+            commit = value
+            continue
+
+        if squash:
+            commit = rev_zero
+
+        if not commits and commit != rev_zero:
+            continue
+
+        value = value.replace("\\n", "\n")
+
+        add_tag(tags, mr, commit, tag, user, value)
+
+        if tag == "at":
+            value = datetime.datetime.utcfromtimestamp(int(value))
+            add_tag(tags, mr, commit, "ai", user, value.isoformat())
+            add_tag(tags, mr, commit, "ad", user, str(value))
+
+        if tag == "AT":
+            value = datetime.datetime.utcfromtimestamp(int(value))
+            add_tag(tags, mr, commit, "AI", user, value.isoformat())
+            add_tag(tags, mr, commit, "AD", user, str(value))
+
+    return tags
+
+def store_user_tags(tags, mr, user, fd):
+    fd.write("@m {}\n".format(mr))
+    fd.write("@u {}\n".format(user))
+    for commit, ctags in tags[mr].items():
+        fd.write("@c {}\n".format(commit))
+        for tag, values in ctags.items():
+            for u, v in values:
+                fd.write("{} {}\n".format(tag, v))
+    fd.write("\n")
+
+def load_user_tags(buf):
+    mr = ""
+    user = ""
+    commit = ""
+    tags = dict()
+
+    for line in buf.split("\n"):
+        if not line:
+            continue
+
+        sline = line.split(None, 1)
+        if len(sline) != 2:
+            sys.exit("Invalid tag line format '{}'.".format(line))
+
+        tag = sline[0].strip()
+        value = sline[1].strip()
+        if not value:
+            sys.exit("Empty tag value '{}'.".format(line))
+
+        if tag == "@m":
+            if mr:
+                sys.exit("More than one MR tag found '{}'.".format(line))
+            mr = value
+            continue
+
+        if tag == "@u":
+            if user:
+                sys.exit("More than one user tag found '{}'.".format(line))
+            user = value
+            continue
+
+        if tag == "@c":
+            commit = value
+            continue
+
+        if not mr:
+            sys.exit("MR tag not found.")
+
+        if not user:
+            sys.exit("User tag not found.")
+
+        if not commit:
+            sys.exit("Commit tag not found.")
+
+        if not tag.isalnum():
+            sys.exit("Invalid tag name '{}'.".format(line))
+
+        add_tag(tags, mr, commit, tag, user, value)
+
+    return tags
+
+def get_commit_tags(tags, mr, commit):
+    if mr not in tags:
+        return None
+    if commit not in tags[mr]:
+        return None
+    return tags[mr][commit]
+
+def get_tag(ctags, name):
+    if name not in ctags:
+        return None
+    return ctags[name]
+
+def get_tag_values(ctags, name, users=None):
+    tag = get_tag(ctags, name)
+    if not tag:
+        return []
+
+    if users:
+        return [v for u, v in tag if u in users]
+    else:
+        return [v for u, v in tag]
+
+def get_tag_value(ctags, name, user=None, default=""):
+    if user:
+        values = get_tag_values(ctags, name, users=[user])
+    else:
+        values = get_tag_values(ctags, name)
+    if not values:
+        return default
+
+    return values[0]
+
+def git_config(option):
+    rv, out, err = run(["git", "config", option], die=False)
+    if not rv:
+        return out.rstrip()
+    return ""
+
+def get_user():
+    user = git_config("mr.user")
+    if not user:
+        sys.exit("No git-mr user found. Please use 'git config mr.user <user>', where <user> is your username on server.")
+    return user
+
+def editor(text):
+    tmpdir = tempfile.TemporaryDirectory()
+
+    editor = git_config("core.editor")
+    if not editor:
+        editor = os.environ.get('EDITOR','vi')
+
+    fn = "{}/text".format(tmpdir.name)
+    with open(fn, mode="w") as f:
+        f.write(text)
+
+    run([editor, fn], stdout=False, stderr=False)
+
+    lines = list()
+    with open(fn, mode="r") as f:
+        for line in f.readlines():
+            if line[0] == "#":
+                continue
+            lines.append(line)
+
+    return "".join(lines)
+
+def format_patch(mr, mrtags, fd):
+    base = get_tag_value(mrtags, "B")
+    if not base:
+        return
+
+    subj = get_tag_value(mrtags, "S")
+    desc = get_tag_value(mrtags, "D")
+    aname = get_tag_value(mrtags, "AN")
+    aemail = get_tag_value(mrtags, "AE")
+    date = get_tag_value(mrtags, "AD")
+
+    cover = """{}
+Message-ID: <{}.{}@localhost>
+From: {} <{}>
+Subject: [MR] {}
+Date: {}
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+{}
+""".format(from_line, mr, rev_zero, aname, aemail, subj, date, desc)
+    fd.write(cover.encode())
+
+    rv, out, err = run(["git", "--no-pager", "format-patch", "--stdout",
+        "--no-thread", "{}..{}".format(base, mr)], text=False)
+
+    fromre = re.compile(b"From [0-9a-f]{40} Mon Sep 17 00:00:00 2001")
+    for line in out.split(b"\n"):
+        if fromre.match(line):
+            fd.write("{}\n".format(from_line).encode())
+            rev = line.split(None, 2)[1].decode()
+            fd.write("Message-ID: <{}.{}@localhost>\n".format(mr, rev).encode())
+            fd.write("In-Reply-To: <{}.{}@localhost>\n".format(mr, rev_zero).encode())
+        else:
+            fd.write(line + b"\n")
+
+def store_mbox(mrs, oldrefs, currefs, newrefs, fd):
+    oldmrs = set(filter_revs(oldrefs, mrs=mrs, types=["head"]))
+    curmrs = set(filter_revs(currefs, mrs=mrs, types=["head"]))
+    newmrs = curmrs - oldmrs
+
+    tags = load_tags(newmrs, refs=currefs, commits=False)
+
+    for mr in newmrs:
+        mrtags = get_commit_tags(tags, mr, rev_zero)
+        if not mrtags:
+            continue
+        format_patch(mr, mrtags, fd)
+
+    for mr in mrs:
+        oldmbs = set(filter_revs(oldrefs, mrs=[mr], types=["mbox"]))
+        curmbs = set(filter_revs(currefs, mrs=[mr], types=["mbox"]))
+        common = oldmbs & curmbs
+        oldmbs -= common
+        curmbs -= common
+
+        if not curmbs:
+            continue
+
+        stdin = ""
+        for rev in curmbs:
+            stdin += "{}\n".format(rev)
+
+        for rev in oldmbs:
+            stdin += "^{}\n".format(rev)
+
+        rv, out, err = run(["git", "rev-list", "--stdin"], stdin=stdin)
+
+        fmt = "{}%n\
+From: %an <%ae>%n\
+Reply-To: %an <%ae>%n\
+Subject: %s%n\
+Date: %aD%n\
+Message-ID: <{}.%H@localhost>%n\
+MIME-Version: 1.0%n\
+Content-Type: text/plain; charset=UTF-8%n\
+Content-Transfer-Encoding: 8bit%n\
+%b".format(from_line, mr)
+        rv, out, err = run(["git", "show", "--format={}".format(fmt), "--stdin"],
+                stdin=out.encode(), text=False)
+
+        fd.write(out)
+
+    newrefs.clear()
+    newrefs += filter_refs(oldrefs, not_mrs=mrs, types=["head", "mbox"])
+    newrefs += filter_refs(currefs, mrs=mrs, types=["head", "mbox"])
+
+def commit_file(fn, name, msg, parent=None, msg_fn=None):
+    tmpdir = tempfile.TemporaryDirectory()
+    idxfn = "{}/index".format(tmpdir.name)
+
+    if fn:
+        rv, out, err = run(["git", "hash-object", "-w", fn])
+        fnsha = out.rstrip()
+
+        rv, out, err = run(["git", "update-index", "--add", "--cacheinfo", "100644",
+            fnsha, name], env={"GIT_INDEX_FILE": idxfn})
+
+    rv, out, err = run(["git", "write-tree"], env={"GIT_INDEX_FILE": idxfn})
+    treesha = out.rstrip()
+
+    gitcmd = ["git", "commit-tree", treesha]
+
+    if parent:
+        gitcmd += ["-p", parent]
+
+    if msg:
+        gitcmd += ["-m", msg]
+
+    if msg_fn:
+        gitcmd += ["-F", msg_fn]
+
+    rv, out, err = run(gitcmd)
+    return out.rstrip()
+
+def update_ref(ref, commit=None, delete=False, oldrev=False):
+    cmd = ["git",  "update-ref"]
+
+    if delete:
+        cmd += ["-d", ref]
+    elif oldrev:
+        cmd += [ref, commit, oldrev]
+    else:
+        cmd += [ref, commit]
+
+    rv, out, err = run(cmd)