├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── design.org ├── setup.py ├── src ├── _version.py ├── dummy ├── ed25519.py ├── git-lockup-template └── setup-lockup.py ├── test_git_lockup.py └── versioneer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | src/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /_test_temp/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "pypy" 6 | install: 7 | - echo "no dependencies to install" 8 | before_script: 9 | - git config --global user.email "foo@example.com" 10 | - git config --global user.name "foo" 11 | script: 12 | - python setup.py build 13 | - python setup.py test 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include versioneer.py 3 | 4 | include src/_version.py 5 | include src/dummy 6 | include src/git-lockup-template 7 | include src/setup-lockup.py 8 | include src/ed25519.py 9 | 10 | include test_git_lockup.py 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | create-sample: 2 | git init sample 3 | ln -s ../../../hooks/post-commit sample/.git/hooks/post-commit 4 | chmod +x hooks/post-commit 5 | 6 | commit: 7 | date >>sample/date.txt 8 | cd sample && git add date.txt && git commit -m "commit" 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | git-lockup : sign and verify author signatures on git commits 2 | ============================================================= 3 | 4 | [![Build Status](https://travis-ci.org/warner/git-lockup.png?branch=master)](https://travis-ci.org/warner/git-lockup) 5 | 6 | This tool makes it easy to "lock" your git checkout to the author's signing key, meaning you'll get the correct commits even if the network (or an intermediate repository like Github) is trying to convince you otherwise. 7 | 8 | If you're just following a repository: 9 | 10 | * do your `git clone` 11 | * you'll find a script named `./setup-lockup` in the new tree 12 | * run it 13 | * that will configure git-lockup with the author's embedded public key 14 | * now every `git fetch` or `git pull` you do from the origin repository will check signatures before allowing the fetch to go through 15 | 16 | If you're publishing a tree: 17 | 18 | * run `git-lockup setup-publish` in your source tree 19 | * that will create a keypair and configure a post-commit hook to sign commits 20 | * it will also create `setup-lockup` and git-add it for inclusion in your tree 21 | 22 | ## Dependencies 23 | 24 | git-lockup is a self-contained python program. All crypto uses a pure-python implementation of the ed25519 signature system by djb. All python dependencies are in the standard library. 25 | 26 | ## Compatibility 27 | 28 | git-lockup has been tested against python2.6 and python2.7 . It might work on other versions, I don't know yet. 29 | 30 | ## Security Model 31 | 32 | ## Implementation Details 33 | 34 | git-lockup creates a short Ed25519 signature for each (branch, commitid) pair. The signature is stored in a "git note" associated with the commit id, in a line that looks like: 35 | 36 | lockup: refs/heads/$BRANCH=$COMMITID sig0-$SIG vk0-$VERFKEY 37 | 38 | (you can use `git notes show HEAD` to see the signature for the current revision). 39 | 40 | The publisher's .git/hooks/post-commit hook is modified to run `git-lockup post-commit` after each commit, which examines .git/config to see what key should be used to sign the current branch (if any). The .git/config `[remote]` section is also modified to push the notes (`refs/notes/commits`) in addition to the usual matching branches, so the signatures get pushed to upstream repos any time the commits get pushed. 41 | 42 | On the receiving side, the git checkout's .git/config is modified to replace the remote URL with a special proxy (url=`ext::.git/git-lockup ARGS`) that gets control whenever you try to fetch from that remote. The proxy fetches everything (commits and notes) to a temporary remote (`$NAME-lockup-temp`) and checks the signatures. If it likes what it sees, the proxy reports the new branch heads back to the caller, which then looks at the local object store and discovers that all the necessary objects are already present, so it doesn't ask the proxy to fetch any objects (simplifying the code considerably). 43 | 44 | The signature verification asserts that: 45 | 46 | * the new branch head is signed by one of the keys listed in the receiver's config file 47 | * the new branch head is a descendant of the current branch head (to prevent rollback attacks) 48 | 49 | To minimize deployment problems, git-lockup is distributed as a single file executable. It is assembled from multiple source files, and when run, it copies all or part of itself into the git tree (`.git/git-lockup` and `setup-lockup`). It's not exactly a quine, but it comes close. 50 | 51 | ## Tricks 52 | 53 | If you need to bypass the fetch-time verification checks, just use the explicit-URL form, e.g. `git fetch https://github.com/USER/REPO BRANCH`, which puts the results in a special reference named `FETCH_HEAD`. Once it's in FETCH_HEAD, you can merge it into your current branch. git-lockup only gets involved when you do `git fetch REMOTENAME`. 54 | 55 | If you need to allow multiple authors to sign commits, you have two options. The first is to have them all to share a single signing key (which lives in .git/config, in `[branch NAME].lockup-sign-key`). The second is to use multiple signing keys: put multiple `[branches].NAME` lines (one per key) into the distributed `lockup.config` file. 56 | 57 | To manually add a new author to an existing checkout, edit .git/config and add the additional verifying keys to (multple) `[branch NAME].lockup-key` entries. You can also re-run `./setup-lockup` and it will change your .git/config to use the current contents of `lockup.config`. 58 | 59 | ## Contact 60 | 61 | Please file bugs and patches at the Github project: http://github.com/warner/git-lockup 62 | 63 | git-lockup is: 64 | 65 | * by Brian Warner 66 | * released into the [public domain](https://creativecommons.org/publicdomain/zero/1.0/) to enable no-fuss embedding 67 | -------------------------------------------------------------------------------- /design.org: -------------------------------------------------------------------------------- 1 | #+STARTUP: hidestars 2 | 3 | git-lockup: Ensure the validity of your git checkouts 4 | 5 | Also consider https://www.agwa.name/projects/git-crypt/ or 6 | https://github.com/blake2-ppc/git-remote-gcrypt for confidentiality. 7 | 8 | * usage 9 | - this package has a bunch of source code, but only creates a single .py 10 | output file, "git-assure" 11 | - committers should run "git assure setup-push" from inside a .git tree 12 | - it will compile python-ed25519 and install a hook script that runs on 13 | 'git push' 14 | - then it will ask for a key name (usually the name of the project), and 15 | create the private key in ~/.git-assure/KEYNAME.signingkey 16 | - it will store key name (but not the key itself) in an unused .git 17 | directory. It will also record the public verifying key there. 18 | - it will also ask for a place to write the user scripts to. This should 19 | be some sort of "misc/" directory in your source tree. It will write 20 | three files there: the original "assure" script, the public verifying 21 | key, and a README.git-assure with install instructions for users. These 22 | three files should be commited and published in your git repo. You may 23 | also want to add notes to your top-level developer-oriented README file 24 | pointing at these instructions. 25 | - users (i.e. your developers who do 'git clone' and 'git pull', but not 26 | necessarily 'git push'), should see these instructions, which will tell 27 | them to run "assure setup-pull" 28 | - when they run that, it will compile python-ed25119 and install a hook 29 | script that runs on 'git fetch' and 'git pull' 30 | - the public verifying key will be copied out of the source tree into an 31 | unised .git directory. This prevents it from being modified by git 32 | operations like 'pull' or 'checkout'. 33 | * data storage 34 | ** ideally put the signatures in the repo with 'git notes', attached to each 35 | revision 36 | *** when a committer starts to use this, the new refs/notes/commits branch 37 | needs to be pushed manually 38 | *** a 'git clone' after that branch is present will pick up the notes 39 | *** but if the 'git clone' was done before the branch appeared, a subsequent 40 | 'git pull' won't 41 | *** so "assure setup-pull" should get them too 42 | *** 'git clone' from a github repo with refs/notes/commits doesn't get them 43 | - 'git ls-remote' shows them 44 | - 'git fetch origin refs/notes/commits:refs/notes/commits' works 45 | - but it doesn't seem sticky: normal 'git fetch' doesn't update it 46 | - ah, probably since the default in .git/config is fetch = 47 | +refs/heads/*:refs/remotes/origin/* 48 | - may need to add a +refs/notes/commits:refs/notes/commits during setup 49 | - I think the same is true for push 50 | *** TODO hm, needs more work. Looks appropriate, but moving them around 51 | requires some care during setup 52 | ** if that doesn't work, put them in a separate github "Gist", and store the 53 | repo URL of it next to the pubkey 54 | * signature format 55 | ** one signature per line 56 | ** message body is like "master=633641174a7bf18e49bdef581d31fdfcc603d39e" 57 | ** sample: signing.key=unhexlify("7369676e302def04727e9a23d8fa1a66787300df0c6781edde5adbb17c68488c33ee1919447a") 58 | ** lines are "assure: BODY SIG KEY", like: 59 | - assure: master=633641174a7bf18e49bdef581d31fdfcc603d39e sig0-7mul5nbgdghmu4mywgtpjsj3skrxyxcwub7apnunlzcgzyw2awvdcaycfuymvxqvuzijd5hxfxpgeztmjuhvisapcq3aj3utfbk56dq verf0-2gy3ublnkjoeyorb234vkqqajlm5mgej3koepk4hr6aqkmm2wuwq 60 | ** 'git notes' has provisions to cleanly merge+dedup line-based notes 61 | * development 62 | ** this tree will contain a bunch of source code, and a build step will 63 | create the one "assure" output file 64 | *** that will be a python program with commands like "install-push" and 65 | "install-pull" 66 | *** it will contain a ascii-encoded copy of python-ed25119 67 | - without the 2.5MB known-answer-tests, it's a 65kB .zip, 86kB base64 68 | *** it'll compile that in a tempdir, and install into a quiet .git/ dir 69 | - .git/ASSURE-TOOLS maybe? 70 | - the hook script will add it to sys.path before 'import ed25519' 71 | ** DEV PLAN: 72 | *** study hooks, identify the right ones 73 | **** "post-commit" for outbound: no parms, cannot affect outcome of git 74 | commit 75 | **** we really want a "pre-merge", but there isn't one 76 | - pre-receive only says it runs on the server side 77 | **** "post-merge" (takes a single "is a squash merge" flag) 78 | - runs after merge, and cannot affect the outcome 79 | - but it could reset the branch back to an earlier (good) version 80 | - it also doesn't help us check parentage 81 | - we need to check that the new rev is a descendant of the old rev 82 | - which means we must know what the old rev was 83 | **** post-commit isn't run after a pull or merge, only 'git checkout' 84 | **** hm, we could use a proxy, or a magic remote protocol 85 | **** oh, I think post-merge has enough information: 86 | ***** we know what HEAD we're on afterwards (say "master") 87 | ***** use the reflog to find out what master was beforehand (can't tolerate 88 | octopus merges) 89 | ***** examine master's current revision to identify all its parents 90 | ***** one of the parents is master@{1}, so ignore that 91 | ***** use git-config to find out what master's upstream branch is 92 | ***** compare the other parent against the current value of the upstream 93 | branch, this identifies a normal merge 94 | ***** now do the signature check against that upstream branch value 95 | ***** and do the parentage check against our remembered upstream value 96 | ***** remember that upstream value for the next time 97 | **** hey, 'git reflog refs/remotes/origin/master' 98 | ***** so first, figure out what the upstream branch is 99 | ***** then find out what the current value is. If that is a parent of the 100 | current post-merged HEAD, then this was an upstream pull, so we need 101 | to check stuff 102 | ***** we've remembered some previous value of the upstream as valid. Check 103 | that the new value of upstream is value and that it is a descendant of 104 | the remembered value, then update our memory. 105 | **** huh, that's hard stuf 106 | ***** so one tool to start with would be just a checker: look at all remote 107 | branches, check each one (current value is signed, and is a descendant 108 | of a previously accepted value). 109 | ****** we'd prefer to run this during during fetch, just before setting 110 | refs/heads/remotes/REMOTE/BRANCH to the new value, where we'd like to 111 | abort the assignment on failure. 112 | ****** we could also run it after fetch (but before merge), in which case 113 | we'd roll back the REMOTE/BRANCH ref to the previously-accepted value 114 | ****** we can run this during the existing post-merge hook, and learn about 115 | historical problems, but if there were any problems, mitigation is 116 | tricky 117 | ****** to help with that, the routine should return (last-accepted, 118 | current-bad) for each problem branch 119 | ***** second routine is to figure out whether the recent merge was affected 120 | ****** since the post-merge hook runs immediately after each merge, we can 121 | use the current branch's reflog to find out its previous value, and 122 | at the current revision's parents to figure out it's history 123 | ****** we might also take advantage of knowing this branch's upstream name 124 | ****** we ignore the parent that equals reflog[-1] 125 | ****** then there's a set of merge scenarios: 126 | ******* lots of holes, especially if the user does a bunch of fetches (but 127 | not merges, so we don't get control), then merges in some 128 | intermediate value 129 | ******* toughest case is probably: 130 | ******** upstream pushes signed values for both branch "master" and branch 131 | "evil" 132 | ******** attacker tries to trick user into getting "evil" when merging from 133 | master 134 | ******** by the time post-merge happens, we've lost information about what 135 | they were trying to merge from. If they did 'git merge 136 | origin/evil', then it's fine. If they did 'git merge origin/master' 137 | and got the evil rev, then that's an attack. 138 | ******** parentage tells us which revision was being merged, but not the 139 | semantics (which branch name was being used) 140 | ******** might glean it from the merge comments? ick. 141 | **** probably safer to simulate a fetch-hook by using a separate remote 142 | ***** upon install, replace the remote with a special handler, and move the 143 | original to e.g. "origin-raw" 144 | ***** the replacement URL would be like "gitlock::origin-raw" 145 | ***** then write a remote-helper for scheme "gitlock" that starts with a 146 | normal git-fetch of the raw remote, then checks the branch values 147 | before copying them into the processed one. 148 | ***** hm, pushes would need handling too, should just pass-through, but 149 | update the branch values. 150 | - git-fetch documents a [url NEWBASE]insteadOf=OLDBASE and 151 | pushInsteadOf=OLDBASE which can rewrite urls differently for pushes 152 | and pulls 153 | ***** would be nice if git exposed its handler for git/ssh/rsync protocols 154 | - transport.c line 917 155 | - get_refs_via_connect, fetch_refs_via_pack, git_transport_push, 156 | connect_git, disconnect_git 157 | - connect.c line 447 git_connect() 158 | ****** 'git push' uses 'git-send-pack [[user@]host:]repopath' on the near 159 | side, and runs git-receive-pack on the far side 160 | ****** 'git fetch' uses 'git-upload-pack' on the far side and runs 161 | 'git-fetch-pack [host:]repopath' on the near side 162 | **** easier special-remote (still a hassle): 163 | ***** git config set remote.NAME.vcs ASSURE 164 | ***** then exec(git-remote-ASSURE, REMOTENAME, URL) 165 | ***** our git-remote-ASSURE starts by doing 'git fetch REMOTENAME-raw', let 166 | it run to completion 167 | ***** then examine all branches in REMOTENAME-raw, check signatures and 168 | parentage, throw exception (exit with rc=1) upon problems, then the 169 | real 'git fetch' will report a remote error. Bonus points for getting 170 | the error message to stderr. 171 | ***** then we "just" need to implement the real remote operations 172 | ***** easiest is to advertise "connect" capability, then parse URL and 173 | simulate git's builtin connection handlers 174 | ****** if URL happens to be http/https, just exec git-remote-http 175 | ******* do this before interpreting any part of the protocol, let 176 | git-remote-http handle everything 177 | ******* 'git-remote-http' uses http-fetch.c and http-push.c 178 | ****** else, need to parse URL (ssh/git/file), advertise "connect", wait for 179 | the connect command to be issued with a 'service' argument, then: 180 | ******* if ssh, exec[ssh host git-receive-pack|git-upload-pack (args..?)] 181 | ******** maybe check for some .git/config options (using something other than 182 | git-receive-pack, etc) 183 | ******* if git:, exec netcat and maybe send a command name 184 | ******** my flappserver handler (git-remote-pb) does this 185 | ******* if URL is file:, exec[git-(receive|upload)-pack] 186 | **** ok, my git-remote-passthrough is coming together 187 | ***** cases to test: 188 | ****** DONE HELPER::rest_of_url 189 | ****** rsync: 190 | ****** DONE /path/to/local 191 | ****** DONE file:///path/to/local 192 | ****** TODO git://host/path 193 | - doesn't work yet, I think the git protocol has an extra message 194 | ****** git://host:port/path 195 | ****** DONE ssh://host/path 196 | ****** DONE ssh://host/~/path 197 | ****** DONE ssh://user@host:port/path 198 | ****** DONE other ssh synonyms: git+ssh, ssh+git 199 | ****** DONE helper://rest 200 | ****** DONE host:path 201 | - luther:/tmp/t.git 202 | ****** actual URL values: 203 | url = /Users/warner/stuff/vc/git/git-assure/t/one 204 | #url = file:///Users/warner/stuff/vc/git/git-assure/t/one 205 | #url = luther:/tmp/t.git 206 | #url = ssh://luther/tmp/t.git 207 | #url = ssh://luther:22/tmp/t.git 208 | #url = ssh://warner@luther:22/tmp/t.git 209 | #url = ssh://luther/~/t.git 210 | #url = ssh://luther/~warner/t.git 211 | #url = ssh+git://luther/tmp/t.git 212 | #url = git+ssh://luther/tmp/t.git 213 | ##url = git://luther:9418/tmp/t.git 214 | #url = https://github.com/warner/python-ed25519.git 215 | #url = passthrough::https://github.com/warner/python-ed25519.git 216 | vcs = passthrough 217 | **** nov-2012, does git make this any easier now? 218 | ***** git-remote-fd lets you set up your remote connection first, attach it 219 | to some spare fds, then run 'git fetch fd::12,13' to bypass (manually 220 | control) the connection setup phase. It then speaks the git protocol 221 | over those fds (or a single bidirectional one). 222 | ***** git-remote-ext is similar, but takes a command to spawn that will 223 | create the remote connection (it then speaks the git protocol over 224 | stdin/stdout of the child process). 225 | ***** git-remote-helpers is a python library that provides local-repo 226 | commands (list-references, get object, etc) to build remotes that 227 | manipulate local repos more easily 228 | ***** no post-fetch hook yet. 229 | ***** to simulate a post-fetch hook: 230 | ****** do real fetch to some parallel/related remote 231 | ****** run post-fetch hook (which might raise an error) 232 | ****** copy refs from the parallel remote to the real one 233 | ***** hm. maybe 3 remotes: A,B,C. "A" is the real upstream, so when the merge 234 | finally happens, it will pull from A. The URL for A points to our 235 | special helper, somehow. When the helper gets control, it first resets 236 | all of B's refs to whatever is in C. Then it does a normal 'git fetch 237 | B', which grabs everything without checking, then runs the post-fetch 238 | hook. If the hook passes, it copies the B refs to C, then copies the B 239 | refs to A, then exits with success. 240 | ***** that allows the upstream to be reset without persistently breaking the 241 | local copy (C will always be good). Oh, A is enough for that. 242 | ***** two remotes: real, temp. 'real.url' points at the special helper, 243 | 'temp.url' points at the real remote repo (during setup, just copy 244 | real.url into temp.url [for push only]). The magic remote-type in 245 | real.url gives control to the helper. The helper overwrites temp's refs 246 | with those from 'real', then does 'git fetch temp', then runs the 247 | post-fetch hook, then maybe copies the new refs from temp back to real. 248 | Maybe even create 'temp' each time, then delete it afterwards. Problem: 249 | the top-level git-fetch has to be negated somehow, turned into a NOP. 250 | ***** oh, better: special helper does: copy real refs to temp, 'git fetch 251 | temp', run post-fetch hook, then uses the "connect" capability and 252 | execs git-upload-pack pointing at the local repo. Modify the 253 | read.refspec to pull from refs/remotes/temp/* instead of refs/heads/* . 254 | Then the top-level 'git fetch' will do the temp-to-real ref copy, and 255 | we don't have to figure out how to NOP it. 256 | ****** all fetches will fetch all branches, unless there's a way to glean the 257 | 'git fetch' arguments in the remote-helper and pass them into the 'git 258 | fetch temp' command. Most likely outcome is limitations on the 259 | original 'git fetch' command will be ignored. 260 | *** outbound 261 | **** DONE script to create signature to stdout, using system-installed ed25519 262 | **** DONE then add it to a 'git notes' 263 | **** then figure out what .git/config is necessary to push notes 264 | *** inbound 265 | **** DONE script to extract note 266 | **** script to check signature, check parentage 267 | **** attach to hook script 268 | **** figure out .git/config needed to pull notes 269 | - maybe pull them from the hook script, slightly slower 270 | *** then packaging: 271 | **** change scripts to use PYTHONPATH=.git/private 272 | **** figure out receiver-side installer 273 | **** figure out sender-side installer 274 | **** figure out installer-builder 275 | * replay protection 276 | ** if enabled, just assert that the previous value of the branch is an 277 | ancestor of the new proposed version. Git takes care of the rest. 278 | * studying fetch.c 279 | ** do_fetch() 280 | *** get_ref_map() 281 | *** fetch_refs() 282 | **** transport_fetch_refs 283 | **** store_updated_refs() writes the file 284 | ** ok, I think the problem is that the [branch "master"].remote and .merge 285 | pair don't point at the same thing that [remote "origin"].fetch does 286 | *** changing .merge to say "refs/remotes/origin-temp/master" works 287 | *** fetch.c L177 is the relevant section 288 | *** hm, add_merge_config() is probably more relevant 289 | ** hm, $GIT_TRANSPORT_HELPER_DEBUG=1 enables remote-helper debug messages (in 290 | transport-helper.c) 291 | ** transport dispatch is in transport.c:transport_get (line 912) 292 | *** explicit helper (config .vcs or url=HELPER::stuff) is handled first 293 | *** then rsync: is dispatched to native code (get_refs_via_rsync, etc) 294 | *** then local/file is dispatched natively (get_refs_from_bundle/etc) 295 | *** then builtin smart transports are checked (non-url, file:, git:, ssh:) 296 | *** then unknown protocols are dispatched to external helper 297 | *** so: rsync/local/git/ssh can't be reached from outside 'git fetch' 298 | * hrm. basic potential strategies: 299 | ** allow the merge to happen, use the post-merge hook to examine the results, 300 | use the reflog to roll back if denied. 301 | *** cons: uncommitted changes more likely to be lost, reflog pollution, 302 | working tree thrash 303 | ** get control with a remote-helper 304 | *** pros: everything happens pre-merge, so no reflog pollution 305 | *** then 1: fetch upstream with an alternative remote, examine, copy to real 306 | remote by passing through to "git upload-pack ." and pointing config's 307 | remote.fetch at refs/remotes/ALT 308 | **** cons: using a remote.fetch like that doesn't update the right stuff 309 | *** then 2: fetch with real remote, examine, throw exception on reject 310 | **** cons 311 | ***** tracking branches are left with evil data, 312 | ****** but, they'll be updated by a subsequent fetch 313 | ***** subsequent manual merge would accept evil 314 | ***** tracking branches don't provide a handy "what was good" blessed history 315 | to prevent replay attacks (but the real local branch provides that) 316 | *** then 3: fetch with real remote, examine, roll back and throw exception on 317 | reject 318 | **** leaves tracking branches in a better state, and can be used for blessed 319 | history 320 | *** but, how to get the real remote to work? 321 | **** in the protocol handler, we can run a recursive 'git fetch' with the 322 | real URL on the same remote.. that will populate the tracking branches. 323 | **** then examine+reject 324 | **** but then how can we let the original fetch succeed and populate 325 | FETCH_HEAD correctly? need to let the protocol-handler do *something* 326 | *** then 4: fetch with alternate remote, examine, then transform into a 327 | non-connect protocol helper. When the driver asks for what references the 328 | far side holds, respond with the refs that were just fetched (under their 329 | remote names, e.g. refs/heads/master). With luck, the driver will then 330 | stop talking, because those refs are already present in the alternate 331 | remote. 332 | **** can use 'git ls-remote' to get the remote refs in exactly the same 333 | format that the protocol-helper "list" command wants 334 | **** ah, but that introduces a TOUTTOC bug 335 | **** so, need to fetch the canonical remote-ref list first, then populate the 336 | raw remote (with 'git fetch', hopefully with the same thing, but we 337 | don't rely on it), then examine refs from the canonical list, then 338 | return the canonical list 339 | **** could we do the verification with just the list of refs? Only if we give 340 | up on replay defense (which needs to know the ancestry relationships 341 | between a previously-valid value and the new proposed value). 342 | ***** oh, actually, what we really need to know is that the proposed value is 343 | new (not in the known history). The upstream publisher will only sign 344 | things that are descendants, so any signed+new value must be a 345 | descendant of our most-recent blessed value. Then we *don't* need to 346 | pull everything first. Much easier. 347 | ***** also means the post-fetch hook isn't really post-fetch: it only gets to 348 | see the proposed new refs, and cannot examine the history or the actual 349 | tree/file contents. 350 | ***** if the hook wants to look deeper, it can manually fetch each proposed 351 | ref 352 | **** ok, so ls-remote gets a full list of refs. Which ones will we care 353 | about? 354 | ***** style 1: validate during 'git fetch', bad references won't even get 355 | added to the remote-tracking branch. This enables arbitrary git 356 | pull/fetch/merge operations. This can use protocol helpers to get 357 | control in the middle of the fetch. The virtual hook we're providing 358 | would be called "pre-fetch" or "post-fetch with rejection abilities", 359 | or maybe "mid-fetch" if it only gets to see the refs and not the actual 360 | contents. 361 | ****** If we do it this way, we really need to roll back the tracking 362 | branches upon error, otherwise discrete 'git fetch; git merge' 363 | commands won't protect the user ('git fetch && git merge' would). 364 | ****** ah, but if we throw at the midpoint, the fetch will fail, and the 365 | tracking branches won't ever be updated. 366 | ***** style 2: validate during 'git merge', remote-tracking branches will 367 | have bad refs but they don't be merged into local branches. This only 368 | protects users during the merge step. We have fewer hooks to implement 369 | this (post-merge, which would need the reflog-based 370 | rollback-on-rejection scheme). The virtual hook could be called 371 | "pre-merge" since it effectively gets to reject merges. 372 | ***** Maybe the git-assure config should associated a key with each refname 373 | ("key-abc123.. refs/heads/master"), rather than associated keys with 374 | local tracking branch names. 375 | ** go with mid-fetch: 376 | *** get control with a protocol helper 377 | *** the helper does a ls-remote, gets the ref list, passes to the hook for 378 | judgement. If the hook needs more information than just the reference 379 | value, it must fetch individual refs (and must not modify the real 380 | tracking branch). The hook can look in the git-assure config to find the 381 | key+remoterefname mapping, so it knows which to examine and which to 382 | ignore 383 | **** [#A] option 1: hook is given the reflist text on stdin, exits with 0 to 384 | accept, !=0 to reject 385 | **** option 2: hook is given raw remote name in argv, must do its own 386 | ls-remote, must return reflist text on stdout 387 | **** option 3: hook gets raw remote name in argv, does its own ls-remote, 388 | returns validated subset of reflist on stdout. helper rejects unless 389 | every ref that the hook returned matches the reflist it sees. 390 | *** the helper then needs to pull the right refs into a raw remote, so the 391 | objects will be local, so the helper doesn't need to implement the 392 | 'fetch' command. It can just spawn 'git fetch --no-tags remote-raw'. This 393 | might fetch evil references, but they won't go beyond the raw remote. And 394 | the raw remote can be deleted immediately (they won't be gc'ed right 395 | away). 396 | *** then the helper returns the original ls-remote list to the driver, which 397 | should then terminate. If the raw fetch didn't supply enough refs, the 398 | driver will ask for 'fetch', which will return an error. 399 | ** to avoid $PATH changes or installation step, need different kind of proxy 400 | *** intercept the "git" protocol instead of the remote-helper protocol 401 | *** protocol is more complex, but should have the same basic functions 402 | *** url = ext::./.git/TOOL REMOTENAME REALURL 403 | **** quick testing suggests it's executed from the repo basedir, which means 404 | the TOOL path we embed can be relative, allowing the repo to be moved 405 | around without breakage 406 | *** git/Documentation/technical/pack-protocol.txt "Reference Discovery" says 407 | that the server should respond with "pkt-line stream" of references: each 408 | line starts with a 4-char hex (004a) length (including the length 409 | length), then the hex revid, then a space, then the refname, then a 410 | newline (included in the length, but ignored after unpacking). The first 411 | line should also have \0 and a list of space-separated capabilities (all 412 | included in the length), but I think my proxy can skip that (older 413 | clients wouldn't have it). The last line should be just "0000". 414 | *** so my proxy can start by running git-ls-remote and run the mid-fetch 415 | hook, then report the ref list in the expected format. At that point the 416 | client ought to disconnect, with nothing to do. 417 | * setup needed: 418 | ** given a remote name: 419 | - set remote.NAME.pushurl = remote.NAME.url 420 | - set remote.NAME.url = ext::.git/TOOL REMOTE URL 421 | - set remote.NAME.assure = KEY for BRANCH 422 | - multiple lines for multiple branches 423 | - add .git/TOOL, chmod+X 424 | ** for publishers, add: 425 | - add branch.NAME.assure-key with the signing+verifying keys 426 | - add remote.NAME.push refspec that pushes notes too. Default is ":", 427 | which means "matching branches". Should have two lines, one with ":" and 428 | one with "refs/notes/commits:refs/notes/commits" 429 | * tools to build / things to fix 430 | ** DONE Figure out how to avoid $PATH changes. Using "assure::" searches for 431 | git-remote-assure. I wish git-remote-ext would do it, but no, that's only 432 | for the "connect" protocol. 433 | *** maybe there's a .git/config setting to modify $PATH during git commands? 434 | I see entries for difftool and mergetool.TOOL.path 435 | *** transport-helper.c:get_helper() (line 128) is the relevant bit 436 | *** maybe hack it with gitcredentials? you can configure a path to the 437 | credential helper. But it looks like only C code can request access to 438 | the credentials API. 439 | *** if I run the "smart" git protocol, I could use core.gitProxy, or 440 | git-remote-ext 441 | *** look for how libexec/git-core is baked into the search path. 442 | run-command.c is probably relevant. 443 | ** rely on $GIT_DIR instead of $cwd 444 | ** build a python-ed25519 -API -compliant form of the pure-python 445 | dholth-ed25519ll code, in a single file 446 | ** the pure-python form takes about 20ms to verify a signature.. consider 447 | excluding unchanged branches from the signature check, to save time, when 448 | there are dozens or hundreds of branches. 449 | ** DONE build tools to assemble the right scripts from source pieces 450 | ** consider abandoning the mid-fetch hook and doing everything inside 451 | git-remote-assure, probably simpler to construct a single file than two 452 | separate ones with overlapping contents 453 | ** DONE build the tools in reverse order: 454 | *** subscriber either clones and then runs ./setup-assure, or pre-installs 455 | git-assure and runs "git-assure clone KEY URL" 456 | **** setup-assure contains an embedded key (one per branch) 457 | **** Running setup-assure installs .git/TOOL (call it "assure-tool"), then 458 | runs "TOOL subscribe BRANCH KEY" 459 | **** "TOOL subscribe" modifies .git/config to run "TOOL fetch" on 460 | fetch, and to add the verifying key for that branch 461 | *** publisher installs git-assure, runs "git-assure setup-publish" 462 | **** that first installs .git/TOOL 463 | **** that then runs "TOOL setup-publish --create-keypair" for the 464 | publish-specific parts: create keypair, store in .git/config, add 465 | post-commit hook (to run "TOOL sign") 466 | **** then it creates and git-adds setup-assure, advises to commit and push 467 | - by reading out the key in .git/config created by setup-publish 468 | - contents of setup-publish include a copy of .git/TOOL 469 | **** ?then it runs "TOOL subscribe" to perform setup-assure steps 470 | - seems useful for shared-publisher arrangements, but requires doing 471 | subscriber setup on each branch 472 | **** (this way, anyone with a checkout can become a publisher by just 473 | learning the signing key: they run ".git/TOOL setup-publish" and fill in 474 | the key) 475 | *** I build+publish git-assure, from smaller pieces 476 | ** 477 | ** setup-publish --create-keypair should set branch.NAME.assure-key in 478 | addition to .assure-sign-key : once you start publishing sigs, you should 479 | expect sigs back. That will also trigger the setup-assure creation to 480 | include those keys 481 | ** DONE maybe put the list of "branches which are supposed to be signed" in a 482 | separate file? What happens when you add one later.. should we re-generate 483 | setup-assure? 484 | ** DONE merge setup_assure_header_b64 and _footer_b64. The keyconfig doesn't 485 | depend upon the header, so just build keyconfig+setup_assure_b64. The 486 | header has only a welcome comment, but since the keyconfig is pretty 487 | short, you'd still see the welcome message even if it weren't at the 488 | tippy-top of the file. 489 | ** DONE merge git-assure and assure-tool? just invoke it in different ways? 490 | *** "git-assure setup-publish" needs a copy of git-assure. use argv[0], read 491 | its contents, base64-encode, include inside the generated setup-assure 492 | *** setup-client is included in assure-tool, but unused (only called from 493 | setup-assure) 494 | ** DONE change substitution code in setup.py to wrap each interpolated block 495 | with "== BEGIN/END $name ==", then remove same from assure-tool-template 496 | ** DONE move .git/config-changing code out of setup-assure and into 497 | "assure-tool subscribe". maybe. the fact that this part is driven by the 498 | embedded keyconfig suggests otherwise. 499 | ** DONE fewer substitutions. "sign" and "report" are only used in assure-tool 500 | ** DONE in post-commit-hook.template, run "git-assure sign" instead of 501 | "assure-tool post-commit", since internally it will run sign() anyways. 502 | Likewise the client-side url configuration calls "assure-tool fetch", 503 | which internally runs assure_proxy(), and should be renamed to make things 504 | easier to follow. 505 | *** hm, "assure-tool post-commit" is probably the best name: hooks are a very 506 | specific environment, whereas "sign" as a command name sounds like it 507 | could be run by a human. So stick with "post-commit" and change the 508 | internals to match. 509 | ** DONE general approach: build an end-to-end test, then start breaking 510 | things 511 | ** change signature code: if pynacl or python-ed25519 is installed globally, 512 | use it (faster), but always be able to fall back to the included 513 | pure-python version 514 | ** DONE src/assure-proxy.py uses os.unlink(.git/FETCH_HEAD), maybe should use 515 | "git update-ref --delete FETCH_HEAD" instead 516 | ** DONE new name: "git-lockup"? 517 | ** DONE setup-publish: git add the "setup-assure" and "assure.config" files 518 | ** DONE setup.py build: create git-assure in a mktmp file, not build/temp, 519 | it's kind of in the way for tab-completion of build/[TAB] 520 | ** make setup-assure locate the assure.config file (next to argv[0]) and pass 521 | it as an argument into "git-assure setup-client". Default usage is to put 522 | setup-assure in the top of the project tree, but make it possible to go 523 | into misc/ or something. Consider moving assure.config to .assure.cfg or 524 | .assure.yaml or something, in which case setup-assure should look for it 525 | in the top of the git tree. 526 | ** implement "subscribe". maybe just rename setup-client and make sure its 527 | idempotent. maybe rename setup-publish to just "publish" 528 | * current design (08-Oct-2013) 529 | ** first publisher must have a copy of git-assure 530 | ** they run "git-assure setup-publish" in their project tree 531 | *** that adds .git/assure-tool 532 | *** calls .git/assure-tool setup-publish --create-keypair master 533 | **** that creates a keypair, updates .git/config, adds post-commit hook 534 | *** then creates ./setup-assure for clients, configured with a verfkey for 535 | every branch that has a "branch.NAME.assure-key" in .git/config, and 536 | includes a copy of assure-tool 537 | ** subscribers run ./setup-assure 538 | *** ./setup-assure has the keyconfig from the publisher, plus a copy of 539 | assure-tool 540 | *** adds .git/assure-tool 541 | *** for each configured branch, calls setup_client() to modify .git/config 542 | **** set remote.NAME.url to ext::.git/assure-tool fetch REMOTE RAWURL 543 | **** set remote.NAME.pushurl (to either RAWURL or a pre-existing pushurl) 544 | **** add branch.NAME.assure-key if not already set 545 | * new design: 546 | ** first publisher has copy of git-lockup, runs "git lockup setup-publish" 547 | *** that has a default behavior of --create-keypair=master 548 | *** it copies git-lockup into .git/git-lockup verbatim 549 | *** then creates a keypair, updates .git/config, adds post-commit hook 550 | *** then creates ./setup-lockup for clients, which include a copy of 551 | git-lockup 552 | *** then creates lockup.config with the branch info 553 | ** post-commit hook runs ".git/git-lockup post-commit", which signs 554 | ** subscribers run ./setup-lockup 555 | *** that dumps .git/git-lockup 556 | *** then runs .git/git-lockup setup-client 557 | **** which reads lockup.config, modifies .git/config: 558 | ***** set remote.NAME.url to "ext::.git/git-lockup fetch-proxy REMOTE RAWURL" 559 | ***** set remote.NAME.pushurl (to either RAWURL or a pre-existing pushurl) 560 | ***** add branch.NAME.lockup-key if not already set 561 | 562 | * bugs 563 | ** DONE setup-lockup needs a shbang 564 | ** DONE publish config is no longer pushing the normal branches (it pushes 565 | notes, but "push = :" is no longer enough to trigger the defaults) 566 | *** probably a newer git thing 567 | *** manually doing a "git push origin master" fixes it, once 568 | *** also, do a push *before* doing setup-publish, to establish a matching 569 | branch 570 | ** 'git pull' couldn't find notes 571 | *** maybe add fetch=+refs/notes/commits:refs/notes/commits to config? 572 | *** maybe fetch notes to a temporary FETCH_HEAD and then merge with "git 573 | notes merge -s cat_sort_uniq FETCH_HEAD". May need this on the publisher 574 | side too. 575 | *** or.. fetch notes to a distinct ref ("refs/notes/lockup"?), don't include 576 | the lockup ref in refspec, don't touch refs/notes/commits, merge with 577 | cat_sort_uniq, use "git notes --ref refs/notes/lockup show" to access. 578 | Publisher uses same ref name. If you have more than one publisher, they 579 | must use this merge for pulling (although really, the default merge would 580 | work unless you have multiple branches pointing at the same commit, or 581 | two different publishers sign the same commit). 582 | *** http://thread.gmane.org/gmane.comp.version-control.git/222644/focus%3D222812 583 | has some proposals about how to manage remote sharing of 'notes' refs 584 | **** https://github.com/aspiers/git-config/blob/master/bin/git-rnotes 585 | **** he uses "git fetch $remote refs/notes/$name:refs/notes/$remote/$name" 586 | with name="commits" and "git notes merge -v refs/notes/$remote/$name" 587 | ** DONE then 'git update-ref --delete' failed 588 | *** actually spelled '-d' 589 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os, base64, tempfile 4 | from distutils.core import setup, Command 5 | from distutils.command.build_scripts import build_scripts 6 | 7 | import versioneer 8 | versioneer.VCS = "git" 9 | versioneer.versionfile_source = "src/_version.py" 10 | versioneer.versionfile_build = None 11 | versioneer.tag_prefix = "" 12 | versioneer.parentdir_prefix = "git-lockup-" 13 | 14 | LONG_DESCRIPTION="""\ 15 | Sign+verify author signatures on git commits. 16 | 17 | By running one command, developers who publish changes to a public git 18 | repository will automatically add signatures to each revision. Followers who 19 | clone the repo can run one command to setup automatic checking of those 20 | signatures. Once enabled, followers will only accept genuine commits from the 21 | upstream author(s). 22 | 23 | Signatures are created in a post-commit hook, and stored (one line per 24 | revision) using the "git-notes" feature, where they can be pushed and fetched 25 | just like regular branches. On the receiving side, the signatures are checked 26 | before the remote tracking branch is ever modified, so all "git fetch" and 27 | "git pull" commands that reference the specific remote will be safe. 28 | 29 | The tools require python but no other dependencies. 30 | """ 31 | 32 | commands = versioneer.get_cmdclass().copy() 33 | 34 | substitutions = {} 35 | def construct(source): 36 | output = [] 37 | f = open(os.path.join("src", source)) 38 | for line in f.readlines(): 39 | if line.startswith("#tmp "): 40 | continue 41 | if line.startswith("#<--"): 42 | name = line.replace("#<--", "").strip() 43 | if name not in substitutions: 44 | raise ValueError("unrecognized substitution '%s' in '%s'" % (name, source)) 45 | if not name.endswith("-b64"): 46 | output.append("##### == BEGIN %s ==\n" % name) 47 | output.append(substitutions[name]) 48 | if not name.endswith("-b64"): 49 | output.append("##### == END %s ==\n" % name) 50 | else: 51 | output.append(line) 52 | return "".join(output) 53 | def add_substitution(name, source): 54 | substitutions[name] = construct(source) 55 | def add_base64_substitution(name, source): 56 | b64 = base64.b64encode(construct(source)) 57 | lines = [b64[i:i+60] for i in range(0, len(b64), 60)] 58 | substitutions[name] = "\n".join(lines)+"\n" 59 | def add_literal_substitution(name, data): 60 | substitutions[name] = data 61 | 62 | 63 | class my_build_scripts(build_scripts): 64 | def run(self): 65 | version = versioneer.get_version() 66 | add_literal_substitution("version", 'version = "%s"\n' % version) 67 | add_substitution("ed25519", "ed25519.py") 68 | add_base64_substitution("setup-lockup-b64", "setup-lockup.py") 69 | tempdir = tempfile.mkdtemp() 70 | git_lockup = os.path.join(tempdir, "git-lockup") 71 | with open(git_lockup, "wb") as f: 72 | f.write(construct("git-lockup-template")) 73 | 74 | # modify self.scripts with the source pathname of scripts to install 75 | # into self.build_dir . When we upcall, those scripts will be copied 76 | # and adjusted (their shbang line set to sys.executable). 77 | self.scripts = [git_lockup] 78 | rc = build_scripts.run(self) 79 | os.unlink(git_lockup) 80 | os.rmdir(tempdir) 81 | return rc 82 | commands["build_scripts"] = my_build_scripts 83 | 84 | class Test(Command): 85 | description = "run tests" 86 | user_options = [] 87 | def initialize_options(self): 88 | pass 89 | def finalize_options(self): 90 | pass 91 | def run(self): 92 | import test_git_lockup 93 | test_git_lockup.unittest.main(module=test_git_lockup, argv=["dummy"]) 94 | commands["test"] = Test 95 | 96 | setup(name="git-lockup", 97 | version=versioneer.get_version(), 98 | description="sign+verify git commits", 99 | long_description=LONG_DESCRIPTION, 100 | author="Brian Warner", 101 | author_email="warner-git-lockup@lothar.com", 102 | license="MIT", 103 | url="https://github.com/warner/git-lockup", 104 | scripts=["src/dummy"], # this will be replaced in my_build_scripts 105 | cmdclass=commands, 106 | ) 107 | -------------------------------------------------------------------------------- /src/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.12 (https://github.com/warner/python-versioneer) 10 | 11 | # these strings will be replaced by git during git-archive 12 | git_refnames = " (HEAD -> master)" 13 | git_full = "f93da2da0a383afd95b6a3106ed80b8fe6839a19" 14 | 15 | # these strings are filled in when 'setup.py versioneer' creates _version.py 16 | tag_prefix = "" 17 | parentdir_prefix = "git-lockup-" 18 | versionfile_source = "src/_version.py" 19 | 20 | import os, sys, re, subprocess, errno 21 | 22 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 23 | assert isinstance(commands, list) 24 | p = None 25 | for c in commands: 26 | try: 27 | # remember shell=False, so use git.cmd on windows, not just git 28 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 29 | stderr=(subprocess.PIPE if hide_stderr 30 | else None)) 31 | break 32 | except EnvironmentError: 33 | e = sys.exc_info()[1] 34 | if e.errno == errno.ENOENT: 35 | continue 36 | if verbose: 37 | print("unable to run %s" % args[0]) 38 | print(e) 39 | return None 40 | else: 41 | if verbose: 42 | print("unable to find command, tried %s" % (commands,)) 43 | return None 44 | stdout = p.communicate()[0].strip() 45 | if sys.version >= '3': 46 | stdout = stdout.decode() 47 | if p.returncode != 0: 48 | if verbose: 49 | print("unable to run %s (error)" % args[0]) 50 | return None 51 | return stdout 52 | 53 | 54 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 55 | # Source tarballs conventionally unpack into a directory that includes 56 | # both the project name and a version string. 57 | dirname = os.path.basename(root) 58 | if not dirname.startswith(parentdir_prefix): 59 | if verbose: 60 | print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % 61 | (root, dirname, parentdir_prefix)) 62 | return None 63 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 64 | 65 | def git_get_keywords(versionfile_abs): 66 | # the code embedded in _version.py can just fetch the value of these 67 | # keywords. When used from setup.py, we don't want to import _version.py, 68 | # so we do it with a regexp instead. This function is not used from 69 | # _version.py. 70 | keywords = {} 71 | try: 72 | f = open(versionfile_abs,"r") 73 | for line in f.readlines(): 74 | if line.strip().startswith("git_refnames ="): 75 | mo = re.search(r'=\s*"(.*)"', line) 76 | if mo: 77 | keywords["refnames"] = mo.group(1) 78 | if line.strip().startswith("git_full ="): 79 | mo = re.search(r'=\s*"(.*)"', line) 80 | if mo: 81 | keywords["full"] = mo.group(1) 82 | f.close() 83 | except EnvironmentError: 84 | pass 85 | return keywords 86 | 87 | def git_versions_from_keywords(keywords, tag_prefix, verbose=False): 88 | if not keywords: 89 | return {} # keyword-finding function failed to find keywords 90 | refnames = keywords["refnames"].strip() 91 | if refnames.startswith("$Format"): 92 | if verbose: 93 | print("keywords are unexpanded, not using") 94 | return {} # unexpanded, so not in an unpacked git-archive tarball 95 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 96 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 97 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 98 | TAG = "tag: " 99 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 100 | if not tags: 101 | # Either we're using git < 1.8.3, or there really are no tags. We use 102 | # a heuristic: assume all version tags have a digit. The old git %d 103 | # expansion behaves like git log --decorate=short and strips out the 104 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 105 | # between branches and tags. By ignoring refnames without digits, we 106 | # filter out many common branch names like "release" and 107 | # "stabilization", as well as "HEAD" and "master". 108 | tags = set([r for r in refs if re.search(r'\d', r)]) 109 | if verbose: 110 | print("discarding '%s', no digits" % ",".join(refs-tags)) 111 | if verbose: 112 | print("likely tags: %s" % ",".join(sorted(tags))) 113 | for ref in sorted(tags): 114 | # sorting will prefer e.g. "2.0" over "2.0rc1" 115 | if ref.startswith(tag_prefix): 116 | r = ref[len(tag_prefix):] 117 | if verbose: 118 | print("picking %s" % r) 119 | return { "version": r, 120 | "full": keywords["full"].strip() } 121 | # no suitable tags, so we use the full revision id 122 | if verbose: 123 | print("no suitable tags, using full revision id") 124 | return { "version": keywords["full"].strip(), 125 | "full": keywords["full"].strip() } 126 | 127 | 128 | def git_versions_from_vcs(tag_prefix, root, verbose=False): 129 | # this runs 'git' from the root of the source tree. This only gets called 130 | # if the git-archive 'subst' keywords were *not* expanded, and 131 | # _version.py hasn't already been rewritten with a short version string, 132 | # meaning we're inside a checked out source tree. 133 | 134 | if not os.path.exists(os.path.join(root, ".git")): 135 | if verbose: 136 | print("no .git in %s" % root) 137 | return {} 138 | 139 | GITS = ["git"] 140 | if sys.platform == "win32": 141 | GITS = ["git.cmd", "git.exe"] 142 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 143 | cwd=root) 144 | if stdout is None: 145 | return {} 146 | if not stdout.startswith(tag_prefix): 147 | if verbose: 148 | print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) 149 | return {} 150 | tag = stdout[len(tag_prefix):] 151 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 152 | if stdout is None: 153 | return {} 154 | full = stdout.strip() 155 | if tag.endswith("-dirty"): 156 | full += "-dirty" 157 | return {"version": tag, "full": full} 158 | 159 | 160 | def get_versions(default={"version": "unknown", "full": ""}, verbose=False): 161 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 162 | # __file__, we can work backwards from there to the root. Some 163 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 164 | # case we can only use expanded keywords. 165 | 166 | keywords = { "refnames": git_refnames, "full": git_full } 167 | ver = git_versions_from_keywords(keywords, tag_prefix, verbose) 168 | if ver: 169 | return ver 170 | 171 | try: 172 | root = os.path.abspath(__file__) 173 | # versionfile_source is the relative path from the top of the source 174 | # tree (where the .git directory might live) to this file. Invert 175 | # this to find the root from __file__. 176 | for i in range(len(versionfile_source.split(os.sep))): 177 | root = os.path.dirname(root) 178 | except NameError: 179 | return default 180 | 181 | return (git_versions_from_vcs(tag_prefix, root, verbose) 182 | or versions_from_parentdir(parentdir_prefix, root, verbose) 183 | or default) 184 | -------------------------------------------------------------------------------- /src/dummy: -------------------------------------------------------------------------------- 1 | This is a fake script, used to ensure that setup.py's scripts= list is 2 | non-empty. The real script is generated (by modifying a template) during the 3 | "setup.py build_scripts" command (run by "setup.py build"). If scripts= was 4 | empty, then build_scripts wouldn't get run (as distutils would mistakenly 5 | believe that this package produced no scripts). 6 | -------------------------------------------------------------------------------- /src/ed25519.py: -------------------------------------------------------------------------------- 1 | 2 | # single-file pure-python ed25519 digital signatures, rearranged to minimize 3 | # the namespace pollution so this can be embedded in another file. Adapted 4 | # from https://bitbucket.org/dholth/ed25519ll 5 | 6 | 7 | # Ed25519 digital signatures 8 | # Based on http://ed25519.cr.yp.to/python/ed25519.py 9 | # See also http://ed25519.cr.yp.to/software.html 10 | # Adapted by Ron Garret 11 | # Sped up considerably using coordinate transforms found on: 12 | # http://www.hyperelliptic.org/EFD/g1p/auto-twisted-extended-1.html 13 | # Specifically add-2008-hwcd-4 and dbl-2008-hwcd 14 | 15 | def Ed25519(): 16 | # don't add many names to the file we're copied into 17 | 18 | try: # pragma nocover 19 | unicode 20 | PY3 = False 21 | def asbytes(b): 22 | """Convert array of integers to byte string""" 23 | return ''.join(chr(x) for x in b) 24 | def joinbytes(b): 25 | """Convert array of bytes to byte string""" 26 | return ''.join(b) 27 | def bit(h, i): 28 | """Return i'th bit of bytestring h""" 29 | return (ord(h[i//8]) >> (i%8)) & 1 30 | 31 | except NameError: # pragma nocover 32 | PY3 = True 33 | asbytes = bytes 34 | joinbytes = bytes 35 | def bit(h, i): 36 | return (h[i//8] >> (i%8)) & 1 37 | 38 | import hashlib 39 | 40 | b = 256 41 | q = 2**255 - 19 42 | l = 2**252 + 27742317777372353535851937790883648493 43 | 44 | def H(m): 45 | return hashlib.sha512(m).digest() 46 | 47 | def expmod(b, e, m): 48 | if e == 0: return 1 49 | t = expmod(b, e // 2, m) ** 2 % m 50 | if e & 1: t = (t * b) % m 51 | return t 52 | 53 | # Can probably get some extra speedup here by replacing this with 54 | # an extended-euclidean, but performance seems OK without that 55 | def inv(x): 56 | return expmod(x, q-2, q) 57 | 58 | d = -121665 * inv(121666) 59 | I = expmod(2,(q-1)//4,q) 60 | 61 | def xrecover(y): 62 | xx = (y*y-1) * inv(d*y*y+1) 63 | x = expmod(xx,(q+3)//8,q) 64 | if (x*x - xx) % q != 0: x = (x*I) % q 65 | if x % 2 != 0: x = q-x 66 | return x 67 | 68 | By = 4 * inv(5) 69 | Bx = xrecover(By) 70 | B = [Bx % q,By % q] 71 | 72 | #def edwards(P,Q): 73 | # x1 = P[0] 74 | # y1 = P[1] 75 | # x2 = Q[0] 76 | # y2 = Q[1] 77 | # x3 = (x1*y2+x2*y1) * inv(1+d*x1*x2*y1*y2) 78 | # y3 = (y1*y2+x1*x2) * inv(1-d*x1*x2*y1*y2) 79 | # return (x3 % q,y3 % q) 80 | 81 | #def scalarmult(P,e): 82 | # if e == 0: return [0,1] 83 | # Q = scalarmult(P,e/2) 84 | # Q = edwards(Q,Q) 85 | # if e & 1: Q = edwards(Q,P) 86 | # return Q 87 | 88 | # Faster (!) version based on: 89 | # http://www.hyperelliptic.org/EFD/g1p/auto-twisted-extended-1.html 90 | 91 | def xpt_add(pt1, pt2): 92 | (X1, Y1, Z1, T1) = pt1 93 | (X2, Y2, Z2, T2) = pt2 94 | A = ((Y1-X1)*(Y2+X2)) % q 95 | B = ((Y1+X1)*(Y2-X2)) % q 96 | C = (Z1*2*T2) % q 97 | D = (T1*2*Z2) % q 98 | E = (D+C) % q 99 | F = (B-A) % q 100 | G = (B+A) % q 101 | H = (D-C) % q 102 | X3 = (E*F) % q 103 | Y3 = (G*H) % q 104 | Z3 = (F*G) % q 105 | T3 = (E*H) % q 106 | return (X3, Y3, Z3, T3) 107 | 108 | def xpt_double (pt): 109 | (X1, Y1, Z1, _) = pt 110 | A = (X1*X1) 111 | B = (Y1*Y1) 112 | C = (2*Z1*Z1) 113 | D = (-A) % q 114 | J = (X1+Y1) % q 115 | E = (J*J-A-B) % q 116 | G = (D+B) % q 117 | F = (G-C) % q 118 | H = (D-B) % q 119 | X3 = (E*F) % q 120 | Y3 = (G*H) % q 121 | Z3 = (F*G) % q 122 | T3 = (E*H) % q 123 | return (X3, Y3, Z3, T3) 124 | 125 | def pt_xform (pt): 126 | (x, y) = pt 127 | return (x, y, 1, (x*y)%q) 128 | 129 | def pt_unxform (pt): 130 | (x, y, z, _) = pt 131 | return ((x*inv(z))%q, (y*inv(z))%q) 132 | 133 | def xpt_mult (pt, n): 134 | if n==0: return pt_xform((0,1)) 135 | _ = xpt_double(xpt_mult(pt, n>>1)) 136 | return xpt_add(_, pt) if n&1 else _ 137 | 138 | def scalarmult(pt, e): 139 | return pt_unxform(xpt_mult(pt_xform(pt), e)) 140 | 141 | def encodeint(y): 142 | bits = [(y >> i) & 1 for i in range(b)] 143 | e = [(sum([bits[i * 8 + j] << j for j in range(8)])) 144 | for i in range(b//8)] 145 | return asbytes(e) 146 | 147 | def encodepoint(P): 148 | x = P[0] 149 | y = P[1] 150 | bits = [(y >> i) & 1 for i in range(b - 1)] + [x & 1] 151 | e = [(sum([bits[i * 8 + j] << j for j in range(8)])) 152 | for i in range(b//8)] 153 | return asbytes(e) 154 | 155 | def publickey(sk): 156 | h = H(sk) 157 | a = 2**(b-2) + sum(2**i * bit(h,i) for i in range(3,b-2)) 158 | A = scalarmult(B,a) 159 | return encodepoint(A) 160 | 161 | def Hint(m): 162 | h = H(m) 163 | return sum(2**i * bit(h,i) for i in range(2*b)) 164 | 165 | def signature(m,sk,pk): 166 | sk = sk[:32] 167 | h = H(sk) 168 | a = 2**(b-2) + sum(2**i * bit(h,i) for i in range(3,b-2)) 169 | inter = joinbytes([h[i] for i in range(b//8,b//4)]) 170 | r = Hint(inter + m) 171 | R = scalarmult(B,r) 172 | S = (r + Hint(encodepoint(R) + pk + m) * a) % l 173 | return encodepoint(R) + encodeint(S) 174 | 175 | def isoncurve(P): 176 | x = P[0] 177 | y = P[1] 178 | return (-x*x + y*y - 1 - d*x*x*y*y) % q == 0 179 | 180 | def decodeint(s): 181 | return sum(2**i * bit(s,i) for i in range(0,b)) 182 | 183 | def decodepoint(s): 184 | y = sum(2**i * bit(s,i) for i in range(0,b-1)) 185 | x = xrecover(y) 186 | if x & 1 != bit(s,b-1): x = q-x 187 | P = [x,y] 188 | if not isoncurve(P): raise Exception("decoding point that is not on curve") 189 | return P 190 | 191 | def checkvalid(s, m, pk): 192 | if len(s) != b//4: raise Exception("signature length is wrong") 193 | if len(pk) != b//8: raise Exception("public-key length is wrong") 194 | R = decodepoint(s[0:b//8]) 195 | A = decodepoint(pk) 196 | S = decodeint(s[b//8:b//4]) 197 | h = Hint(encodepoint(R) + pk + m) 198 | v1 = scalarmult(B,S) 199 | # v2 = edwards(R,scalarmult(A,h)) 200 | v2 = pt_unxform(xpt_add(pt_xform(R), pt_xform(scalarmult(A, h)))) 201 | return v1==v2 202 | 203 | 204 | import os 205 | 206 | def create_signing_key(): 207 | seed = os.urandom(32) 208 | return seed 209 | def create_verifying_key(signing_key): 210 | return publickey(signing_key) 211 | 212 | def sign(skbytes, msg): 213 | """Return just the signature, given the message and just the secret 214 | key.""" 215 | if len(skbytes) != 32: 216 | raise ValueError("Bad signing key length %d" % len(skbytes)) 217 | vkbytes = create_verifying_key(skbytes) 218 | sig = signature(msg, skbytes, vkbytes) 219 | return sig 220 | 221 | def verify(vkbytes, sig, msg): 222 | if len(vkbytes) != 32: 223 | raise ValueError("Bad verifying key length %d" % len(vkbytes)) 224 | if len(sig) != 64: 225 | raise ValueError("Bad signature length %d" % len(sig)) 226 | rc = checkvalid(sig, msg, vkbytes) 227 | if not rc: 228 | raise ValueError("rc != 0", rc) 229 | return True 230 | 231 | return (create_signing_key, create_verifying_key, sign, verify) 232 | 233 | (ed25519_create_signing_key, ed25519_create_verifying_key, 234 | ed25519_sign, ed25519_verify) = Ed25519() 235 | 236 | ## sk = ed25519_create_signing_key() 237 | ## msg = "hello world" 238 | ## sig = ed25519_sign(sk, msg) 239 | ## assert len(sig) == 64 240 | ## vk = ed25519_create_verifying_key(sk) 241 | ## ed25519_verify(vk, sig, msg) 242 | ## print "ok" 243 | 244 | -------------------------------------------------------------------------------- /src/git-lockup-template: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """ 4 | I am the globally-installed "git-lockup" tool. I have two user-facing 5 | commands: 6 | * setup-publish: install publishing tools into the local git tree, 7 | use them to create a "setup-lockup" script for 8 | subscribers 9 | * report: print a summary of git-lockup configuration. For every branch in 10 | report whether we sign commits, and whether we require 11 | signatures. This also checks the proxy config settings and the 12 | post-commit hook. 13 | 14 | I am also copied into .git/git-lockup when installed into a particular 15 | source tree: 16 | * by publishers, when they run "git-lockup setup-publish" 17 | * by clients, when they run "./setup-lockup" 18 | 19 | I am called in multiple ways: 20 | * "git-lockup setup-publish" (by publishers), to prepare a tree for 21 | publishing. This may create a keypair (or ask for an existing signing 22 | key), then modify .git/config and add a post-commit hook, then will 23 | create and git-add ./setup-lockup, then will advise the user to commit 24 | and push (to make ./setup-lockup available to clients) 25 | * "git-lockup subscribe" (by setup-lockup, run by clients). This modifies 26 | .git/config to run during a fetch, and adds the verifying key 27 | * "git-lockup fetch" (during git-fetch, run by clients). This intercepts 28 | the fetch process, examines the remote references, checks their 29 | signatures, and allows the fetch to proceed if they are valid. 30 | * "git-lockup post-commit" (during git-commit's post-commit hook, run by 31 | clients). This creates a signature and adds it to the git-notes entry for 32 | the new revision. 33 | """ 34 | 35 | #<-- version 36 | 37 | # note: don't print anything to stdout at the top-level, as stdout is used by 38 | # the proxy when we're in 'fetch' mode. Use stderr instead. It is safe to 39 | # print to stdout from the non-fetch commands, though. 40 | 41 | import re, sys, os, subprocess, base64 42 | import optparse # instead of argparse, since we support py2.6 43 | 44 | def from_ascii(s_ascii): 45 | s_ascii += "="*((8 - len(s_ascii)%8)%8) 46 | s_bytes = base64.b32decode(s_ascii.upper()) 47 | return s_bytes 48 | 49 | def to_ascii(s_bytes): 50 | s_ascii = base64.b32encode(s_bytes).rstrip("=").lower() 51 | return s_ascii 52 | 53 | def remove_prefix(s, prefix, require_prefix=False): 54 | if not s.startswith(prefix): 55 | if require_prefix: 56 | raise ValueError("no prefix '%s' in string '%s'" % (prefix, s)) 57 | return None 58 | return s[len(prefix):] 59 | 60 | def announce(s): 61 | print >>sys.stderr, s 62 | 63 | def debug(s): 64 | #print >>sys.stderr, s 65 | return 66 | 67 | def make_executable(tool): 68 | oldmode = os.stat(tool).st_mode & int("07777", 8) 69 | newmode = (oldmode | int("0555", 8)) & int("07777", 8) 70 | os.chmod(tool, newmode) 71 | 72 | def run_command(args, cwd=None, stdin="", eat_stderr=False, verbose=False): 73 | try: 74 | # remember shell=False, so use git.cmd on windows, not just git 75 | stderr = None 76 | if eat_stderr: 77 | stderr = subprocess.PIPE 78 | p = subprocess.Popen(args, 79 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, 80 | stderr=stderr, 81 | cwd=cwd) 82 | except EnvironmentError: 83 | e = sys.exc_info()[1] 84 | if verbose: 85 | debug("unable to run %s" % args[0]) 86 | debug(e) 87 | return None 88 | stdout = p.communicate(stdin)[0] 89 | if sys.version >= '3': 90 | stdout = stdout.decode() 91 | if p.returncode != 0: 92 | if verbose: 93 | debug("unable to run %s (error)" % args[0]) 94 | return None 95 | return stdout 96 | 97 | def get_config(key): 98 | cmd = ["git", "config", key] 99 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE) 100 | stdout = p.communicate()[0] 101 | if p.returncode == 1: 102 | return None 103 | if p.returncode != 0: 104 | print >>sys.stderr, "Error running '%s': rc=%s" % \ 105 | (" ".join(cmd), p.returncode) 106 | raise Exception() 107 | return stdout.strip() 108 | 109 | def get_all_config(key): 110 | cmd = ["git", "config", "--get-all", key] 111 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE) 112 | stdout = p.communicate()[0] 113 | if p.returncode == 1: 114 | return [] 115 | if p.returncode != 0: 116 | print >>sys.stderr, "Error running '%s': rc=%s" % \ 117 | (" ".join(cmd), p.returncode) 118 | raise Exception() 119 | return stdout.splitlines() 120 | 121 | def get_config_regexp(regexp): 122 | cmd = ["git", "config", "--get-regexp", regexp] 123 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE) 124 | stdout = p.communicate()[0] 125 | if p.returncode == 1: 126 | return None 127 | if p.returncode != 0: 128 | print >>sys.stderr, "Error running '%s': rc=%s" % \ 129 | (" ".join(cmd), p.returncode) 130 | raise Exception() 131 | return stdout.splitlines() 132 | 133 | def get_config_verifykeys(): 134 | # these are the branches we're configured to care about 135 | branches = {} # maps branch name to set of keys 136 | keylines = get_config_regexp(r"^branch\..*\.lockup-key$") 137 | for line in keylines: 138 | mo = re.search(r'^branch\.([^.]*)\.lockup-key\s+([\w\-]+)$', line) 139 | if not mo: 140 | announce("confusing lockup-key line: '%s'" % line) 141 | continue 142 | branch = mo.group(1) 143 | if "/" not in branch: 144 | branch = "refs/heads/"+branch 145 | if branch not in branches: 146 | branches[branch] = set() 147 | branches[branch].add(mo.group(2)) 148 | return branches 149 | 150 | def set_config_raw_urls(remote): 151 | rawurl = get_config("remote.%s.lockup-raw-url" % remote) 152 | rawpushurl = get_config("remote.%s.lockup-raw-pushurl" % remote) 153 | if not rawurl: 154 | rawurl = get_config("remote.%s.url" % remote) 155 | if rawurl: 156 | run_command(["git", "config", 157 | "remote.%s.lockup-raw-url" % remote, rawurl]) 158 | rawpushurl = get_config("remote.%s.pushurl" % remote) 159 | if rawpushurl: 160 | run_command(["git", "config", 161 | "remote.%s.lockup-raw-pushurl" % remote, rawpushurl]) 162 | return rawurl, rawpushurl 163 | 164 | #<-- ed25519 165 | 166 | def read_myself(): 167 | me = os.path.abspath(sys.argv[0]) 168 | assert me.endswith("git-lockup") 169 | return open(me, "rb").read() 170 | 171 | setup_lockup_b64 = """ 172 | #<-- setup-lockup-b64 173 | """ 174 | 175 | def setup_publish_set_hook(): 176 | # once per repo 177 | pc = ".git/hooks/post-commit" 178 | if os.path.exists(pc): 179 | old = open(pc, "rb").read() 180 | if old == post_commit: 181 | return 182 | announce("old .git/hooks/post-commit is in the way") 183 | return 184 | with open(pc, "wb") as f: 185 | f.write("#!/bin/sh\n") 186 | f.write("\n") 187 | f.write("$GIT_DIR/git-lockup post-commit\n") 188 | make_executable(pc) 189 | 190 | def setup_publish_config(create_keypair, branch): 191 | remote = "origin" 192 | setup_publish_set_hook() 193 | 194 | # once per remote 195 | pushes = get_all_config("remote.%s.push" % remote) 196 | if not pushes: 197 | run_command(["git", "config", "--add", "remote.%s.push" % remote, ":"]) 198 | notes_push = "refs/notes/commits:refs/notes/commits" 199 | if notes_push not in pushes: 200 | run_command(["git", "config", "--add", "remote.%s.push" % remote, 201 | notes_push]) 202 | rawurl, rawpushurl = set_config_raw_urls(remote) 203 | 204 | # once per branch 205 | signkey_key = "branch.%s.lockup-sign-key" % branch 206 | verfkey_key = "branch.%s.lockup-key" % branch 207 | old_key = get_config(signkey_key) 208 | if old_key: 209 | print "branch '%s' already has a key configured, ignoring" % branch 210 | sk = from_ascii(remove_prefix(old_key, "sk0-")) 211 | vk_s = "vk0-%s" % to_ascii(ed25519_create_verifying_key(sk)) 212 | else: 213 | sk = ed25519_create_signing_key() 214 | sk_s = "sk0-%s" % to_ascii(sk) 215 | run_command(["git", "config", signkey_key, sk_s]) 216 | print "the post-commit hook will now sign changes on branch '%s'" % branch 217 | vk_s = "vk0-%s" % to_ascii(ed25519_create_verifying_key(sk)) 218 | run_command(["git", "config", verfkey_key, vk_s]) 219 | print "verifykey: %s" % vk_s 220 | 221 | # now create setup-lockup, including this. quine! 222 | 223 | def git_add(fn): 224 | run_command(["git", "add", fn]) 225 | 226 | def setup_publish(subargv): 227 | """I prepare a source tree to add and publish signatures with each 228 | commit. I create a keypair for the 'master' branch and configure a 229 | post-commit hook which will sign each commit. I also create two files 230 | ('setup-lockup' and 'lockup.config') and git-add them to the source tree 231 | so they'll be available to downstream clients, who can run setup-lockup 232 | to configure their tree to verify our signatures. 233 | """ 234 | 235 | parser = optparse.OptionParser(usage="git-lockup setup-publish") 236 | (options, subargs) = parser.parse_args(subargv) 237 | 238 | assert os.path.isdir(".git") 239 | tool = ".git/git-lockup" 240 | me = read_myself() 241 | with open(tool, "wb") as f: 242 | f.write(me) 243 | make_executable(tool) 244 | # this makes a keypair, updates .git/config, and adds a post-commit hook 245 | setup_publish_config(True, "master") 246 | # Then we create ./setup-lockup for clients to use. We must insert a copy 247 | # of ourselves. 248 | setup_lockup = base64.b64decode(setup_lockup_b64) 249 | setup_lockup = setup_lockup.replace("GIT_LOCKUP_B64", base64.b64encode(me)) 250 | with open("setup-lockup", "wb") as f: 251 | f.write(setup_lockup) 252 | make_executable("setup-lockup") 253 | git_add("setup-lockup") 254 | # along with an lockup.config with the branch->key information from 255 | # .git/config 256 | branches = get_config_verifykeys() 257 | with open("lockup.config", "wb") as f: 258 | f.write("# -*- mode: conf; coding: utf-8 -*-\n") 259 | f.write("[branches]\n") 260 | for branch in sorted(branches): 261 | shortbranch = remove_prefix(branch, "refs/heads/", True) 262 | keys = " ".join([key for key in branches[branch]]) 263 | f.write("%s = %s\n" % (shortbranch, keys)) 264 | git_add("lockup.config") 265 | 266 | # then maybe execute TOOL subscribe, not sure yet 267 | 268 | def post_commit(subargv): 269 | """I run as a git post-commit hook, and create a signature for each 270 | revision on the branches that we're configured to manage. 271 | """ 272 | # this is called from .git/hooks/post-commit . The post-commit hook gets 273 | # no arguments, so neither do we. 274 | parser = optparse.OptionParser(usage="git-lockup post-commit") 275 | (options, subargs) = parser.parse_args(subargv) 276 | 277 | print "--" 278 | print "IN POST-COMMIT" 279 | print "CWD is", os.getcwd() 280 | for name in sorted(os.environ): 281 | if name.startswith("GIT"): 282 | print "%s: %s" % (name, os.environ[name]) 283 | print "--" 284 | rev = run_command(["git", "rev-parse", "HEAD"]).strip() 285 | fullbranch = run_command(["git", "rev-parse", "--symbolic-full-name", "HEAD"]).strip() 286 | branch = remove_prefix(fullbranch, "refs/heads/") 287 | if not branch: 288 | print "not commiting to refs/heads/ , ignoring" 289 | sys.exit(0) 290 | pieces = branch.split("/") 291 | if "." in pieces or ".." in pieces: 292 | print "scary branch name %s, ignoring" % branch 293 | sys.exit(0) 294 | print "branch:", branch 295 | print "HEAD:", rev 296 | msg = "%s=%s" % (fullbranch, rev) 297 | print "MSG:", msg 298 | 299 | keys = get_config("branch.%s.lockup-sign-key" % branch) 300 | if not keys: 301 | print "No signing key in .git/config, ignoring" 302 | sys.exit(0) 303 | if not keys.startswith("sk0-"): 304 | raise Exception("Unrecognized signing key format") 305 | sk = from_ascii(remove_prefix(keys, "sk0-")) 306 | vk = "vk0-" + to_ascii(ed25519_create_verifying_key(sk)) 307 | 308 | sig = ed25519_sign(sk, msg) 309 | sig_s = "sig0-"+to_ascii(sig) 310 | line = "lockup: %s %s %s" % (msg, sig_s, vk) 311 | print line 312 | 313 | run_command(["git", "notes", "append", "-m", line, rev]) 314 | print "note added" 315 | 316 | 317 | def setup_client_branch(remote, branch, key): 318 | rawurl, rawpushurl = set_config_raw_urls(remote) 319 | ext_url = "ext::.git/git-lockup fetch-proxy %s %s" % (remote, rawurl) 320 | run_command(["git", "config", "remote.%s.url" % remote, ext_url]) 321 | # we must make sure pushurl is set too, since our proxy doesn't know how 322 | # to push anything. If they already had a pushurl, stick with it. 323 | # Otherwise set pushurl equal to the old raw url. 324 | if not get_config("remote.%s.pushurl" % remote): 325 | run_command(["git", "config", "remote.%s.pushurl" % remote, rawurl]) 326 | print "remote '%s' configured to use verification proxy" % remote 327 | 328 | verfkeys = get_all_config("branch.%s.lockup-key" % branch) 329 | if key in verfkeys: 330 | print "branch '%s' was already configured to verify with key %s" % (branch, key) 331 | else: 332 | run_command(["git", "config", "--add", "branch.%s.lockup-key" % branch, key]) 333 | print "branch '%s' configured to verify with key %s" % (branch, key) 334 | 335 | def setup_client(subargv): 336 | """I am run by a downstream developer, after they've done a git-clone. I 337 | configure the source tree to verify signatures on each commit before 338 | allowing them to be fetched. 339 | """ 340 | parser = optparse.OptionParser(usage="git-lockup setup-client") 341 | (options, subargs) = parser.parse_args(subargv) 342 | 343 | from ConfigParser import SafeConfigParser 344 | config = SafeConfigParser() 345 | config.readfp(open("lockup.config")) 346 | for branch,keys in config.items("branches"): 347 | remote = get_config("branch.%s.remote" % branch) 348 | assert remote 349 | for key in keys.split(): 350 | setup_client_branch(remote, branch, key) 351 | 352 | def proxy_get_all_signatures(revid, upstream_notes_revid, local_notes_revid): 353 | remote_lines = run_command(["git", "show", 354 | "%s:%s" % (upstream_notes_revid, revid)], 355 | eat_stderr=True) or "" 356 | local_lines = run_command(["git", "show", 357 | "%s:%s" % (local_notes_revid, revid)], 358 | eat_stderr=True) or "" 359 | lines = set() 360 | lines.update(remote_lines.splitlines()) 361 | lines.update(local_lines.splitlines()) 362 | return [line.replace("lockup: ", "") 363 | for line in lines 364 | if line.startswith("lockup:")] 365 | 366 | def proxy_validate(git_dir, remote_name, url, all_refs): 367 | all_refs = dict([(name, sha) for (sha, name) in all_refs]) 368 | debug("got %d refs" % len(all_refs)) 369 | 370 | branch_and_keys = get_config_verifykeys() 371 | 372 | # update our list of signatures. We use both the local copy and the 373 | # current upstream. 374 | out = run_command(["git", "rev-parse", "refs/notes/commits"], 375 | eat_stderr=True) 376 | if out is None: 377 | print >>sys.stderr, "Could not find local refs/notes/commits." 378 | print >>sys.stderr, "Maybe you need to pull some." 379 | local_notes_revid = None 380 | else: 381 | local_notes_revid = out.strip() 382 | 383 | out = run_command(["git", "fetch", "--no-tags", url, 384 | "refs/notes/commits"], eat_stderr=False) 385 | if out is None: 386 | print >>sys.stderr, "Could not find refs/notes/commits in the upstream repo." 387 | print >>sys.stderr, "Maybe you (or someone else) needs to push some signatures to it?" 388 | upstream_notes_revid = None 389 | else: 390 | upstream_notes_revid = run_command(["git", "rev-parse", "FETCH_HEAD"]).strip() 391 | run_command(["git", "update-ref", "-d", "FETCH_HEAD"], 392 | eat_stderr=True) 393 | 394 | for branch,keys in branch_and_keys.items(): 395 | if branch not in all_refs: 396 | # tolerate missing branches. This allows lockup= lines to be 397 | # set up in the config file before the named branches are 398 | # actually published. I *think* this is safe and useful, but 399 | # could be convinced otherwise. 400 | continue 401 | proposed_branch_revid = all_refs[branch] 402 | found_good_signature = False 403 | signatures = proxy_get_all_signatures(proposed_branch_revid, 404 | upstream_notes_revid, 405 | local_notes_revid) 406 | for sigline in signatures: 407 | s_body, s_sig, s_key = sigline.split() 408 | if s_key not in keys: 409 | debug("wrong key") 410 | continue # signed by a key we don't recognize 411 | if s_body != ("%s=%s" % (branch, proposed_branch_revid)): 412 | debug("wrong branch or wrong revid") 413 | continue # talking about the wrong branch or revid 414 | assert s_key.startswith("vk0-") 415 | vk = from_ascii(s_key.replace("vk0-", "")) 416 | assert s_sig.startswith("sig0-") 417 | sig = from_ascii(s_sig.replace("sig0-", "")) 418 | try: 419 | ed25519_verify(vk, sig, s_body) 420 | found_good_signature = True 421 | debug("good signature found for branch %s (rev %s)" % (branch, proposed_branch_revid)) 422 | break 423 | except ValueError: 424 | debug("bad signature") 425 | continue 426 | 427 | if not found_good_signature: 428 | announce("no valid signature found for branch %s (rev %s)" % (branch, proposed_branch_revid)) 429 | sys.exit(1) 430 | 431 | # validation good 432 | 433 | def proxy_get_remote_refs(url): 434 | # git-ls-remote returns tab-joined "SHA\tNAME", and we want to format 435 | # it differently. Return a list of (SHA, NAME) tuples. 436 | tab_text = run_command(["git", "ls-remote", url]) 437 | return [tuple(line.split()) for line in tab_text.splitlines()] 438 | 439 | def proxy_fetch_objects(url, orig_refspec, remote_name): 440 | temp_remote = remote_name + "-lockup-temp" 441 | refspec = orig_refspec.replace("refs/remotes/%s/" % remote_name, 442 | "refs/remotes/%s/" % temp_remote) 443 | debug("fetching new refs") 444 | run_command(["git", "fetch", "--no-tags", "--update-head-ok", url, refspec], 445 | eat_stderr=True) 446 | debug("fetched refs") 447 | run_command(["git", "update-ref", "-d", "FETCH_HEAD"], 448 | eat_stderr=True) 449 | # and delete all the temporary tracking branches 450 | temp_refs = set() 451 | for line in run_command(["git", "branch", "--remote"]).splitlines(): 452 | line = line.strip() 453 | if line.startswith(temp_remote): 454 | temp_refs.add(line.replace("%s/" % temp_remote, "")) 455 | for refname in temp_refs: 456 | run_command(["git", "update-ref", "-d", 457 | "refs/remotes/%s/%s" % (temp_remote, refname)]) 458 | debug("deleted temp refs") 459 | 460 | def fetch_proxy(subargv): 461 | """I am invoked as a git-remote proxy, via the 'ext::.git/git-lockup' 462 | pseudo-URL configured in [remote]BRANCHNAME.url . I implement a 463 | line-oriented command protocol, but do most of my work before 464 | interpreting the first command. My job is to fetch the proposed revisions 465 | and check for a good signature on them, before making them visible to the 466 | real 'git-fetch' or 'git-pull' in progress. 467 | """ 468 | debug("ARGS=%s" % (subargv,)) 469 | parser = optparse.OptionParser(usage="git fetch-proxy REMOTENAME URL") 470 | (options, args) = parser.parse_args(subargv) 471 | remote_name, url = args[:2] 472 | 473 | git_dir = os.path.abspath(os.environ["GIT_DIR"]) 474 | debug(git_dir) 475 | 476 | # extract the 'fetch' config for the real remote 477 | refspec = run_command(["git", "config", "remote.%s.fetch" % remote_name]).strip() 478 | debug("REFSPEC: %s" % refspec) 479 | debug("URL: %s" % url) 480 | 481 | # use git-ls-remote to obtain the real list of references. We'll do our 482 | # validation on this list, then return the list to the "git fetch" 483 | # driver. 484 | all_refs = proxy_get_remote_refs(url) 485 | debug("all refs: '%s'" % (all_refs,)) 486 | 487 | # now validate the references. This is the core of git-lockup. It will 488 | # sys.exit(1) if it rejects what it sees. 489 | proxy_validate(git_dir, remote_name, url, all_refs) 490 | 491 | # now fetch all objects into a temporary remote, so that the parent "git 492 | # fetch" won't ask us to provide any actual objects. This simplifies our 493 | # driver considerably. 494 | proxy_fetch_objects(url, refspec, remote_name) 495 | 496 | debug("returning full ref list") 497 | # now return the full reflist 498 | for (sha,name) in all_refs: 499 | line = "%s %s\n" % (sha, name) 500 | sys.stdout.write("%04x" % (4+len(line))) 501 | sys.stdout.write(line) 502 | sys.stdout.write("0000") 503 | sys.stdout.flush() 504 | debug("finished returning full ref list") 505 | 506 | while True: 507 | length = int(sys.stdin.read(4), 16) 508 | if length == 0: 509 | # graceful disconnect 510 | sys.exit(0) 511 | 512 | line = sys.stdin.read(length-4) 513 | debug("COMMAND=%s" % line) 514 | 515 | announce("Hey, don't fetch, you should already have everything") 516 | sys.exit(1) 517 | 518 | def report(subargv): 519 | """I emit a summary of the git-lockup configuration: for every branch 520 | mentioned in .git/config, report whether we sign commits, and whether we 521 | require signatures. I also check on the proxy config and the post-commit 522 | hook. 523 | """ 524 | 525 | parser = optparse.OptionParser(usage="git-lockup report") 526 | (options, subargs) = parser.parse_args(subargv) 527 | 528 | current_branch = None 529 | all_branches = set() 530 | for line in run_command(["git", "branch", "--list"]).splitlines(): 531 | name = line.strip("* ") 532 | if line.startswith("*"): 533 | current_branch = name 534 | all_branches.add(name) 535 | 536 | configured_branches = set() 537 | for line in get_config_regexp("^branch\."): 538 | configured_branches.add(line.split(".")[1]) 539 | 540 | remotes = set() 541 | for line in get_config_regexp("^remote\."): 542 | remotes.add(line.split(".")[1]) 543 | 544 | hook_ready = True 545 | try: 546 | contents = open(".git/hooks/post-commit", "rb").read() 547 | if contents != post_commit: 548 | print "post-commit hook exists, but differs from what I expected" 549 | hook_ready = False 550 | if not os.access(".git/hooks/post-commit", os.X_OK): 551 | print "post-commit hook exists, but is not executable" 552 | hook_ready = False 553 | except EnvironmentError: 554 | print ".git/hooks/post-commit doesn't exist" 555 | hook_ready = False 556 | if hook_ready: 557 | print "post-commit hook is correct and executable" 558 | 559 | for branch in sorted(all_branches): 560 | desc = [] 561 | if branch in configured_branches: 562 | configured = True 563 | signkey = get_config("branch.%s.lockup-sign-key" % branch) 564 | if signkey: 565 | sk = from_ascii(remove_prefix(signkey, "sk0-")) 566 | vk_s = "vk0-"+to_ascii(ed25519_create_verifying_key(sk)) 567 | desc.append("will sign (%s)" % vk_s) 568 | verifykeys = get_all_config("branch.%s.lockup-key" % branch) 569 | for key in set(verifykeys): 570 | desc.append("will verify (%s)" % key) 571 | else: 572 | desc.append("no configuration") 573 | 574 | print "branch %s: %s" % (branch, ", ".join(desc)) 575 | 576 | usage = """ 577 | git-lockup COMMAND [args] 578 | 579 | git-lockup understands the following commands: 580 | setup-publish: run in a git tree, configures for push 581 | report: check/describe the git-lockup configuration 582 | """ 583 | # """ # python-mode is somehow confused by the triple-quote 584 | # the other commands are for internal use 585 | 586 | 587 | def main(argv): 588 | # this would be easier with argparse, but we support py2.6 (which only 589 | # has optparse), and can't really use external dependencies 590 | if len(argv) < 2: 591 | print usage 592 | sys.exit(1) 593 | if argv[1] in ("-h", "--help"): 594 | print usage 595 | sys.exit(0) 596 | if argv[1] in ("-v", "--version"): 597 | print "git-lockup %s" % version 598 | sys.exit(0) 599 | command = argv[1] 600 | subargv = argv[2:] 601 | 602 | if command == "version": 603 | print "git-lockup %s" % version 604 | sys.exit(0) 605 | if command == "setup-publish": 606 | setup_publish(subargv) 607 | elif command == "post-commit": 608 | post_commit(subargv) 609 | elif command == "setup-client": 610 | setup_client(subargv) 611 | elif command == "fetch-proxy": 612 | # this is run by git, so argv[1] is 613 | fetch_proxy(subargv) 614 | elif command == "report": 615 | report(subargv) 616 | else: 617 | print "unknown command '%s'" % command 618 | print usage 619 | sys.exit(1) 620 | 621 | if __name__ == "__main__": 622 | main(sys.argv) 623 | -------------------------------------------------------------------------------- /src/setup-lockup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Welcome to git-lockup! 4 | 5 | # By running this program, your git checkout will be configured to check 6 | # per-revision signatures every time you fetch new changes. This will 7 | # ensure that you only get changes from the upstream author of this 8 | # project, preventing unauthorized commits injected at any intermediate 9 | # repositories or hosting providers. 10 | 11 | import os, sys, base64 12 | 13 | def make_executable(tool): 14 | oldmode = os.stat(tool).st_mode & int("07777", 8) 15 | newmode = (oldmode | int("0555", 8)) & int("07777", 8) 16 | os.chmod(tool, newmode) 17 | 18 | # this is filled in by "git-lockup setup-publish", with a copy of git-lockup. 19 | git_lockup_b64 = """ 20 | GIT_LOCKUP_B64 21 | """ 22 | 23 | # First we install .git/git-lockup 24 | assert os.path.isdir(".git") 25 | tool = os.path.abspath(".git/git-lockup") 26 | f = open(tool, "wb") 27 | f.write(base64.b64decode(git_lockup_b64)) 28 | f.close() 29 | make_executable(tool) 30 | 31 | # Then we run "git-lockup setup-client" to configure everything. This will 32 | # read lockup.config to determine the branch/pubkey list before modifying 33 | # .git/config 34 | os.execv(sys.executable, [sys.executable, tool, "setup-client"]) 35 | -------------------------------------------------------------------------------- /test_git_lockup.py: -------------------------------------------------------------------------------- 1 | 2 | import os, sys, re, subprocess, shutil, unittest 3 | 4 | scriptdir = os.path.abspath("build/scripts-%d.%d" % (sys.version_info[:2])) 5 | ga = os.path.join(scriptdir, "git-lockup") 6 | if not os.path.isdir(scriptdir) or not os.path.exists(ga): 7 | print "'git-lockup' script is missing: please run 'setup.py build'" 8 | sys.exit(1) 9 | os.environ["PATH"] = os.pathsep.join([scriptdir]+ 10 | os.environ["PATH"].split(os.pathsep)) 11 | 12 | class RunnerMixin: 13 | VERBOSE = False 14 | def run_command(self, args, cwd=None, verbose=False): 15 | # this command is expected to succeed. If it fails, we display 16 | # stderr. 17 | if verbose or self.VERBOSE: 18 | print "COMMAND:", args 19 | try: 20 | # remember shell=False, so use git.cmd on windows, not just git 21 | p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, 22 | stderr=subprocess.PIPE) 23 | except EnvironmentError: 24 | e = sys.exc_info()[1] 25 | print("unable to run %s" % args[0]) 26 | print(e) 27 | raise 28 | stdout,stderr = p.communicate() 29 | if sys.version >= '3': 30 | stdout = stdout.decode() 31 | stderr = stderr.decode() 32 | if verbose or self.VERBOSE: 33 | print " rc:", p.returncode 34 | print " out: '%s'" % stdout 35 | print " err: '%s'" % stderr 36 | if p.returncode != 0: 37 | print("unable to run %s (error)" % args[0]) 38 | print("stderr: '%s'" % stderr) 39 | raise ValueError("command failed") 40 | return stdout 41 | 42 | def run_failing_command(self, args, cwd=None, verbose=False): 43 | # this is expected to fail, with rc != 0 44 | if verbose or self.VERBOSE: 45 | print "COMMAND:", args 46 | try: 47 | # remember shell=False, so use git.cmd on windows, not just git 48 | p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, 49 | stderr=subprocess.PIPE) 50 | except EnvironmentError: 51 | e = sys.exc_info()[1] 52 | print("unable to run %s" % args[0]) 53 | print(e) 54 | raise 55 | stdout,stderr = p.communicate() 56 | if sys.version >= '3': 57 | stdout = stdout.decode() 58 | stderr = stderr.decode() 59 | if verbose or self.VERBOSE: 60 | print " rc:", p.returncode 61 | print " out: '%s'" % stdout 62 | print " err: '%s'" % stderr 63 | return (p.returncode, stdout, stderr) 64 | 65 | class BasedirMixin: 66 | def make_basedir(self, testname): 67 | basedir = os.path.join("_test_temp", testname) 68 | if os.path.isdir(basedir): 69 | shutil.rmtree(basedir) 70 | os.makedirs(basedir) 71 | return basedir 72 | 73 | class Create(BasedirMixin, RunnerMixin, unittest.TestCase): 74 | 75 | def subpath(self, path): 76 | return os.path.join(self.basedir, path) 77 | def git(self, *args, **kwargs): 78 | workdir = kwargs.pop("workdir", 79 | self.subpath(kwargs.pop("subdir", "demo"))) 80 | assert not kwargs, kwargs.keys() 81 | output = self.run_command(["git"]+list(args), workdir) 82 | return output.strip() 83 | 84 | def add_change(self, subdir="one", message="more"): 85 | with open(os.path.join(self.subpath(subdir), "README"), "a") as f: 86 | f.write(message+"\n") 87 | self.git("add", "README", subdir=subdir) 88 | self.git("commit", "-m", message, subdir=subdir) 89 | 90 | def assertIn(self, member, container): 91 | if member not in container: 92 | self.fail("'%s' not found in '%s'" % (member, container)) 93 | 94 | def test_run(self): 95 | out = self.run_command(["git-lockup", "--help"]) 96 | self.assertIn("git-lockup understands the following commands", out) 97 | self.assertIn("setup-publish: run in a git tree, configures for push", out) 98 | 99 | def test_setup_help(self): 100 | self.basedir = self.make_basedir("Create.test_setup_help") 101 | self.git("init", workdir=self.basedir) 102 | out = self.run_command(["git-lockup", "setup-publish", "--help"], 103 | cwd=self.basedir) 104 | hookfile = os.path.join(self.basedir, ".git", "hooks", "post-commit") 105 | self.failIf(os.path.exists(hookfile)) 106 | self.assertIn("Usage: git-lockup setup-publish", out) 107 | 108 | def test_setup(self): 109 | self.basedir = self.make_basedir("Create.setup") 110 | upstream = self.subpath("upstream") 111 | os.makedirs(upstream) 112 | one = self.subpath("one") 113 | self.git("init", "--bare", subdir="upstream") 114 | self.git("clone", os.path.abspath(upstream), os.path.abspath(one), 115 | workdir=upstream) 116 | self.add_change(message="initial-unsigned") 117 | # first push needs to be explicit, since we haven't added a 118 | # remote.origin.push refspec in .git/config yet 119 | self.git("push", "origin", "master", subdir="one") 120 | out = self.run_command(["git-lockup", "setup-publish"], one) 121 | self.assertIn("the post-commit hook will now sign changes on branch 'master'", out) 122 | self.assertIn("verifykey: vk0-", out) 123 | vk_s = re.search(r"(vk0-\w+)", out).group(1) 124 | #self.assertIn("you should now commit the generated 'setup-lockup'", out) 125 | # setup-publish automatically adds setup-lockup and lockup.config 126 | 127 | # pyflakes one/.git/lockup-tool 128 | # pyflakes one/setup-lockup 129 | 130 | # now that the publishing repo is configured to sign commits, adding 131 | # a change should get a note with a signature 132 | self.add_change(message="first-signed") 133 | head = self.git("rev-parse", "HEAD", subdir="one") 134 | notes = self.git("notes", "list", head, subdir="one").split("\n") 135 | self.assertEqual(len(notes), 1, notes) 136 | 137 | # the updated refspec should push the notes along with the commits 138 | self.git("push", subdir="one") 139 | 140 | # so they should be present in the upstream (bare) repo 141 | notes = self.git("notes", "list", head, subdir="upstream").split("\n") 142 | self.assertEqual(len(notes), 1, notes) 143 | 144 | # cloning the repo doesn't get the notes by default 145 | two = self.subpath("two") 146 | self.git("clone", os.path.abspath(upstream), os.path.abspath(two), 147 | workdir=upstream) 148 | notes = self.git("notes", subdir="two") 149 | self.assertEqual(notes, "") 150 | 151 | # run the downstream setup script 152 | out = self.run_command([sys.executable, "./setup-lockup"], two) 153 | self.assertIn("remote 'origin' configured to use verification proxy", out) 154 | self.assertIn("branch 'master' configured to verify with key %s" % vk_s, out) 155 | 156 | # now downstream pulls should work, fetch notes, and check signatures 157 | out = self.git("pull", subdir="two") 158 | #self.assertNotIn("Could not find local refs/notes/commits", out) 159 | #print "FIRST PULL", out 160 | 161 | self.add_change(message="second-signed") 162 | self.git("push", subdir="one") 163 | out = self.git("pull", subdir="two") 164 | #print "SECOND PULL", out 165 | one_head = self.git("rev-parse", "HEAD", subdir="one") 166 | two_head = self.git("rev-parse", "HEAD", subdir="two") 167 | self.assertEqual(one_head, two_head) 168 | 169 | # unsigned commits should be rejected by the downstream 170 | unsigned = self.subpath("unsigned") 171 | self.git("clone", os.path.abspath(upstream), os.path.abspath(unsigned), 172 | workdir=upstream) 173 | self.add_change(subdir="unsigned", message="unsigned") 174 | unsigned_head = self.git("rev-parse", "HEAD", subdir="unsigned") 175 | self.assertNotEqual(unsigned, one_head) 176 | self.git("push", subdir="unsigned") 177 | 178 | rc,out,err = self.run_failing_command(["git", "pull"], 179 | self.subpath("two")) 180 | self.assertEqual(rc, 1) 181 | self.assertEqual(out, "") 182 | self.assertIn("\nno valid signature found for branch refs/heads/master (rev %s)\n" % unsigned_head, err) 183 | self.assertIn("\nfatal: Could not read from remote repository.\n", err) 184 | # should fail 185 | 186 | #print "THIRD PULL (unsigned)", out 187 | two_head = self.git("rev-parse", "HEAD", subdir="two") 188 | self.assertNotEqual(two_head, unsigned_head) 189 | 190 | if __name__ == "__main__": 191 | unittest.main() 192 | -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | 2 | # Version: 0.12 3 | 4 | """ 5 | The Versioneer 6 | ============== 7 | 8 | * like a rocketeer, but for versions! 9 | * https://github.com/warner/python-versioneer 10 | * Brian Warner 11 | * License: Public Domain 12 | * Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, and pypy 13 | 14 | [![Build Status](https://travis-ci.org/warner/python-versioneer.png?branch=master)](https://travis-ci.org/warner/python-versioneer) 15 | 16 | This is a tool for managing a recorded version number in distutils-based 17 | python projects. The goal is to remove the tedious and error-prone "update 18 | the embedded version string" step from your release process. Making a new 19 | release should be as easy as recording a new tag in your version-control 20 | system, and maybe making new tarballs. 21 | 22 | 23 | ## Quick Install 24 | 25 | * `pip install versioneer` to somewhere to your $PATH 26 | * run `versioneer-installer` in your source tree: this installs `versioneer.py` 27 | * follow the instructions below (also in the `versioneer.py` docstring) 28 | 29 | ## Version Identifiers 30 | 31 | Source trees come from a variety of places: 32 | 33 | * a version-control system checkout (mostly used by developers) 34 | * a nightly tarball, produced by build automation 35 | * a snapshot tarball, produced by a web-based VCS browser, like github's 36 | "tarball from tag" feature 37 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 38 | 39 | Within each source tree, the version identifier (either a string or a number, 40 | this tool is format-agnostic) can come from a variety of places: 41 | 42 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 43 | about recent "tags" and an absolute revision-id 44 | * the name of the directory into which the tarball was unpacked 45 | * an expanded VCS keyword ($Id$, etc) 46 | * a `_version.py` created by some earlier build step 47 | 48 | For released software, the version identifier is closely related to a VCS 49 | tag. Some projects use tag names that include more than just the version 50 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 51 | needs to strip the tag prefix to extract the version identifier. For 52 | unreleased software (between tags), the version identifier should provide 53 | enough information to help developers recreate the same tree, while also 54 | giving them an idea of roughly how old the tree is (after version 1.2, before 55 | version 1.3). Many VCS systems can report a description that captures this, 56 | for example 'git describe --tags --dirty --always' reports things like 57 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 58 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 59 | uncommitted changes. 60 | 61 | The version identifier is used for multiple purposes: 62 | 63 | * to allow the module to self-identify its version: `myproject.__version__` 64 | * to choose a name and prefix for a 'setup.py sdist' tarball 65 | 66 | ## Theory of Operation 67 | 68 | Versioneer works by adding a special `_version.py` file into your source 69 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 70 | dynamically ask the VCS tool for version information at import time. However, 71 | when you use "setup.py build" or "setup.py sdist", `_version.py` in the new 72 | copy is replaced by a small static file that contains just the generated 73 | version data. 74 | 75 | `_version.py` also contains `$Revision$` markers, and the installation 76 | process marks `_version.py` to have this marker rewritten with a tag name 77 | during the "git archive" command. As a result, generated tarballs will 78 | contain enough information to get the proper version. 79 | 80 | 81 | ## Installation 82 | 83 | First, decide on values for the following configuration variables: 84 | 85 | * `VCS`: the version control system you use. Currently accepts "git". 86 | 87 | * `versionfile_source`: 88 | 89 | A project-relative pathname into which the generated version strings should 90 | be written. This is usually a `_version.py` next to your project's main 91 | `__init__.py` file, so it can be imported at runtime. If your project uses 92 | `src/myproject/__init__.py`, this should be `src/myproject/_version.py`. 93 | This file should be checked in to your VCS as usual: the copy created below 94 | by `setup.py versioneer` will include code that parses expanded VCS 95 | keywords in generated tarballs. The 'build' and 'sdist' commands will 96 | replace it with a copy that has just the calculated version string. 97 | 98 | This must be set even if your project does not have any modules (and will 99 | therefore never import `_version.py`), since "setup.py sdist" -based trees 100 | still need somewhere to record the pre-calculated version strings. Anywhere 101 | in the source tree should do. If there is a `__init__.py` next to your 102 | `_version.py`, the `setup.py versioneer` command (described below) will 103 | append some `__version__`-setting assignments, if they aren't already 104 | present. 105 | 106 | * `versionfile_build`: 107 | 108 | Like `versionfile_source`, but relative to the build directory instead of 109 | the source directory. These will differ when your setup.py uses 110 | 'package_dir='. If you have `package_dir={'myproject': 'src/myproject'}`, 111 | then you will probably have `versionfile_build='myproject/_version.py'` and 112 | `versionfile_source='src/myproject/_version.py'`. 113 | 114 | If this is set to None, then `setup.py build` will not attempt to rewrite 115 | any `_version.py` in the built tree. If your project does not have any 116 | libraries (e.g. if it only builds a script), then you should use 117 | `versionfile_build = None` and override `distutils.command.build_scripts` 118 | to explicitly insert a copy of `versioneer.get_version()` into your 119 | generated script. 120 | 121 | * `tag_prefix`: 122 | 123 | a string, like 'PROJECTNAME-', which appears at the start of all VCS tags. 124 | If your tags look like 'myproject-1.2.0', then you should use 125 | tag_prefix='myproject-'. If you use unprefixed tags like '1.2.0', this 126 | should be an empty string. 127 | 128 | * `parentdir_prefix`: 129 | 130 | a string, frequently the same as tag_prefix, which appears at the start of 131 | all unpacked tarball filenames. If your tarball unpacks into 132 | 'myproject-1.2.0', this should be 'myproject-'. 133 | 134 | This tool provides one script, named `versioneer-installer`. That script does 135 | one thing: write a copy of `versioneer.py` into the current directory. 136 | 137 | To versioneer-enable your project: 138 | 139 | * 1: Run `versioneer-installer` to copy `versioneer.py` into the top of your 140 | source tree. 141 | 142 | * 2: add the following lines to the top of your `setup.py`, with the 143 | configuration values you decided earlier: 144 | 145 | import versioneer 146 | versioneer.VCS = 'git' 147 | versioneer.versionfile_source = 'src/myproject/_version.py' 148 | versioneer.versionfile_build = 'myproject/_version.py' 149 | versioneer.tag_prefix = '' # tags are like 1.2.0 150 | versioneer.parentdir_prefix = 'myproject-' # dirname like 'myproject-1.2.0' 151 | 152 | * 3: add the following arguments to the setup() call in your setup.py: 153 | 154 | version=versioneer.get_version(), 155 | cmdclass=versioneer.get_cmdclass(), 156 | 157 | * 4: now run `setup.py versioneer`, which will create `_version.py`, and will 158 | modify your `__init__.py` (if one exists next to `_version.py`) to define 159 | `__version__` (by calling a function from `_version.py`). It will also 160 | modify your `MANIFEST.in` to include both `versioneer.py` and the generated 161 | `_version.py` in sdist tarballs. 162 | 163 | * 5: commit these changes to your VCS. To make sure you won't forget, 164 | `setup.py versioneer` will mark everything it touched for addition. 165 | 166 | ## Post-Installation Usage 167 | 168 | Once established, all uses of your tree from a VCS checkout should get the 169 | current version string. All generated tarballs should include an embedded 170 | version string (so users who unpack them will not need a VCS tool installed). 171 | 172 | If you distribute your project through PyPI, then the release process should 173 | boil down to two steps: 174 | 175 | * 1: git tag 1.0 176 | * 2: python setup.py register sdist upload 177 | 178 | If you distribute it through github (i.e. users use github to generate 179 | tarballs with `git archive`), the process is: 180 | 181 | * 1: git tag 1.0 182 | * 2: git push; git push --tags 183 | 184 | Currently, all version strings must be based upon a tag. Versioneer will 185 | report "unknown" until your tree has at least one tag in its history. This 186 | restriction will be fixed eventually (see issue #12). 187 | 188 | ## Version-String Flavors 189 | 190 | Code which uses Versioneer can learn about its version string at runtime by 191 | importing `_version` from your main `__init__.py` file and running the 192 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 193 | import the top-level `versioneer.py` and run `get_versions()`. 194 | 195 | Both functions return a dictionary with different keys for different flavors 196 | of the version string: 197 | 198 | * `['version']`: condensed tag+distance+shortid+dirty identifier. For git, 199 | this uses the output of `git describe --tags --dirty --always` but strips 200 | the tag_prefix. For example "0.11-2-g1076c97-dirty" indicates that the tree 201 | is like the "1076c97" commit but has uncommitted changes ("-dirty"), and 202 | that this commit is two revisions ("-2-") beyond the "0.11" tag. For 203 | released software (exactly equal to a known tag), the identifier will only 204 | contain the stripped tag, e.g. "0.11". 205 | 206 | * `['full']`: detailed revision identifier. For Git, this is the full SHA1 207 | commit id, followed by "-dirty" if the tree contains uncommitted changes, 208 | e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac-dirty". 209 | 210 | Some variants are more useful than others. Including `full` in a bug report 211 | should allow developers to reconstruct the exact code being tested (or 212 | indicate the presence of local changes that should be shared with the 213 | developers). `version` is suitable for display in an "about" box or a CLI 214 | `--version` output: it can be easily compared against release notes and lists 215 | of bugs fixed in various releases. 216 | 217 | In the future, this will also include a 218 | [PEP-0440](http://legacy.python.org/dev/peps/pep-0440/) -compatible flavor 219 | (e.g. `1.2.post0.dev123`). This loses a lot of information (and has no room 220 | for a hash-based revision id), but is safe to use in a `setup.py` 221 | "`version=`" argument. It also enables tools like *pip* to compare version 222 | strings and evaluate compatibility constraint declarations. 223 | 224 | The `setup.py versioneer` command adds the following text to your 225 | `__init__.py` to place a basic version in `YOURPROJECT.__version__`: 226 | 227 | from ._version import get_versions 228 | __version__ = get_versions()['version'] 229 | del get_versions 230 | 231 | ## Updating Versioneer 232 | 233 | To upgrade your project to a new release of Versioneer, do the following: 234 | 235 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 236 | * re-run `versioneer-installer` in your source tree to replace your copy of 237 | `versioneer.py` 238 | * edit `setup.py`, if necessary, to include any new configuration settings 239 | indicated by the release notes 240 | * re-run `setup.py versioneer` to replace `SRC/_version.py` 241 | * commit any changed files 242 | 243 | ### Upgrading from 0.10 to 0.11 244 | 245 | You must add a `versioneer.VCS = "git"` to your `setup.py` before re-running 246 | `setup.py versioneer`. This will enable the use of additional version-control 247 | systems (SVN, etc) in the future. 248 | 249 | ### Upgrading from 0.11 to 0.12 250 | 251 | Nothing special. 252 | 253 | ## Future Directions 254 | 255 | This tool is designed to make it easily extended to other version-control 256 | systems: all VCS-specific components are in separate directories like 257 | src/git/ . The top-level `versioneer.py` script is assembled from these 258 | components by running make-versioneer.py . In the future, make-versioneer.py 259 | will take a VCS name as an argument, and will construct a version of 260 | `versioneer.py` that is specific to the given VCS. It might also take the 261 | configuration arguments that are currently provided manually during 262 | installation by editing setup.py . Alternatively, it might go the other 263 | direction and include code from all supported VCS systems, reducing the 264 | number of intermediate scripts. 265 | 266 | 267 | ## License 268 | 269 | To make Versioneer easier to embed, all its code is hereby released into the 270 | public domain. The `_version.py` that it creates is also in the public 271 | domain. 272 | 273 | """ 274 | 275 | import os, sys, re, subprocess, errno 276 | from distutils.core import Command 277 | from distutils.command.sdist import sdist as _sdist 278 | from distutils.command.build import build as _build 279 | 280 | # these configuration settings will be overridden by setup.py after it 281 | # imports us 282 | versionfile_source = None 283 | versionfile_build = None 284 | tag_prefix = None 285 | parentdir_prefix = None 286 | VCS = None 287 | 288 | # these dictionaries contain VCS-specific tools 289 | LONG_VERSION_PY = {} 290 | 291 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 292 | assert isinstance(commands, list) 293 | p = None 294 | for c in commands: 295 | try: 296 | # remember shell=False, so use git.cmd on windows, not just git 297 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 298 | stderr=(subprocess.PIPE if hide_stderr 299 | else None)) 300 | break 301 | except EnvironmentError: 302 | e = sys.exc_info()[1] 303 | if e.errno == errno.ENOENT: 304 | continue 305 | if verbose: 306 | print("unable to run %s" % args[0]) 307 | print(e) 308 | return None 309 | else: 310 | if verbose: 311 | print("unable to find command, tried %s" % (commands,)) 312 | return None 313 | stdout = p.communicate()[0].strip() 314 | if sys.version >= '3': 315 | stdout = stdout.decode() 316 | if p.returncode != 0: 317 | if verbose: 318 | print("unable to run %s (error)" % args[0]) 319 | return None 320 | return stdout 321 | 322 | LONG_VERSION_PY['git'] = ''' 323 | # This file helps to compute a version number in source trees obtained from 324 | # git-archive tarball (such as those provided by githubs download-from-tag 325 | # feature). Distribution tarballs (built by setup.py sdist) and build 326 | # directories (produced by setup.py build) will contain a much shorter file 327 | # that just contains the computed version number. 328 | 329 | # This file is released into the public domain. Generated by 330 | # versioneer-0.12 (https://github.com/warner/python-versioneer) 331 | 332 | # these strings will be replaced by git during git-archive 333 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 334 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 335 | 336 | # these strings are filled in when 'setup.py versioneer' creates _version.py 337 | tag_prefix = "%(TAG_PREFIX)s" 338 | parentdir_prefix = "%(PARENTDIR_PREFIX)s" 339 | versionfile_source = "%(VERSIONFILE_SOURCE)s" 340 | 341 | import os, sys, re, subprocess, errno 342 | 343 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): 344 | assert isinstance(commands, list) 345 | p = None 346 | for c in commands: 347 | try: 348 | # remember shell=False, so use git.cmd on windows, not just git 349 | p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, 350 | stderr=(subprocess.PIPE if hide_stderr 351 | else None)) 352 | break 353 | except EnvironmentError: 354 | e = sys.exc_info()[1] 355 | if e.errno == errno.ENOENT: 356 | continue 357 | if verbose: 358 | print("unable to run %%s" %% args[0]) 359 | print(e) 360 | return None 361 | else: 362 | if verbose: 363 | print("unable to find command, tried %%s" %% (commands,)) 364 | return None 365 | stdout = p.communicate()[0].strip() 366 | if sys.version >= '3': 367 | stdout = stdout.decode() 368 | if p.returncode != 0: 369 | if verbose: 370 | print("unable to run %%s (error)" %% args[0]) 371 | return None 372 | return stdout 373 | 374 | 375 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 376 | # Source tarballs conventionally unpack into a directory that includes 377 | # both the project name and a version string. 378 | dirname = os.path.basename(root) 379 | if not dirname.startswith(parentdir_prefix): 380 | if verbose: 381 | print("guessing rootdir is '%%s', but '%%s' doesn't start with prefix '%%s'" %% 382 | (root, dirname, parentdir_prefix)) 383 | return None 384 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 385 | 386 | def git_get_keywords(versionfile_abs): 387 | # the code embedded in _version.py can just fetch the value of these 388 | # keywords. When used from setup.py, we don't want to import _version.py, 389 | # so we do it with a regexp instead. This function is not used from 390 | # _version.py. 391 | keywords = {} 392 | try: 393 | f = open(versionfile_abs,"r") 394 | for line in f.readlines(): 395 | if line.strip().startswith("git_refnames ="): 396 | mo = re.search(r'=\s*"(.*)"', line) 397 | if mo: 398 | keywords["refnames"] = mo.group(1) 399 | if line.strip().startswith("git_full ="): 400 | mo = re.search(r'=\s*"(.*)"', line) 401 | if mo: 402 | keywords["full"] = mo.group(1) 403 | f.close() 404 | except EnvironmentError: 405 | pass 406 | return keywords 407 | 408 | def git_versions_from_keywords(keywords, tag_prefix, verbose=False): 409 | if not keywords: 410 | return {} # keyword-finding function failed to find keywords 411 | refnames = keywords["refnames"].strip() 412 | if refnames.startswith("$Format"): 413 | if verbose: 414 | print("keywords are unexpanded, not using") 415 | return {} # unexpanded, so not in an unpacked git-archive tarball 416 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 417 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 418 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 419 | TAG = "tag: " 420 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 421 | if not tags: 422 | # Either we're using git < 1.8.3, or there really are no tags. We use 423 | # a heuristic: assume all version tags have a digit. The old git %%d 424 | # expansion behaves like git log --decorate=short and strips out the 425 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 426 | # between branches and tags. By ignoring refnames without digits, we 427 | # filter out many common branch names like "release" and 428 | # "stabilization", as well as "HEAD" and "master". 429 | tags = set([r for r in refs if re.search(r'\d', r)]) 430 | if verbose: 431 | print("discarding '%%s', no digits" %% ",".join(refs-tags)) 432 | if verbose: 433 | print("likely tags: %%s" %% ",".join(sorted(tags))) 434 | for ref in sorted(tags): 435 | # sorting will prefer e.g. "2.0" over "2.0rc1" 436 | if ref.startswith(tag_prefix): 437 | r = ref[len(tag_prefix):] 438 | if verbose: 439 | print("picking %%s" %% r) 440 | return { "version": r, 441 | "full": keywords["full"].strip() } 442 | # no suitable tags, so we use the full revision id 443 | if verbose: 444 | print("no suitable tags, using full revision id") 445 | return { "version": keywords["full"].strip(), 446 | "full": keywords["full"].strip() } 447 | 448 | 449 | def git_versions_from_vcs(tag_prefix, root, verbose=False): 450 | # this runs 'git' from the root of the source tree. This only gets called 451 | # if the git-archive 'subst' keywords were *not* expanded, and 452 | # _version.py hasn't already been rewritten with a short version string, 453 | # meaning we're inside a checked out source tree. 454 | 455 | if not os.path.exists(os.path.join(root, ".git")): 456 | if verbose: 457 | print("no .git in %%s" %% root) 458 | return {} 459 | 460 | GITS = ["git"] 461 | if sys.platform == "win32": 462 | GITS = ["git.cmd", "git.exe"] 463 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 464 | cwd=root) 465 | if stdout is None: 466 | return {} 467 | if not stdout.startswith(tag_prefix): 468 | if verbose: 469 | print("tag '%%s' doesn't start with prefix '%%s'" %% (stdout, tag_prefix)) 470 | return {} 471 | tag = stdout[len(tag_prefix):] 472 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 473 | if stdout is None: 474 | return {} 475 | full = stdout.strip() 476 | if tag.endswith("-dirty"): 477 | full += "-dirty" 478 | return {"version": tag, "full": full} 479 | 480 | 481 | def get_versions(default={"version": "unknown", "full": ""}, verbose=False): 482 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 483 | # __file__, we can work backwards from there to the root. Some 484 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 485 | # case we can only use expanded keywords. 486 | 487 | keywords = { "refnames": git_refnames, "full": git_full } 488 | ver = git_versions_from_keywords(keywords, tag_prefix, verbose) 489 | if ver: 490 | return ver 491 | 492 | try: 493 | root = os.path.abspath(__file__) 494 | # versionfile_source is the relative path from the top of the source 495 | # tree (where the .git directory might live) to this file. Invert 496 | # this to find the root from __file__. 497 | for i in range(len(versionfile_source.split(os.sep))): 498 | root = os.path.dirname(root) 499 | except NameError: 500 | return default 501 | 502 | return (git_versions_from_vcs(tag_prefix, root, verbose) 503 | or versions_from_parentdir(parentdir_prefix, root, verbose) 504 | or default) 505 | ''' 506 | 507 | def git_get_keywords(versionfile_abs): 508 | # the code embedded in _version.py can just fetch the value of these 509 | # keywords. When used from setup.py, we don't want to import _version.py, 510 | # so we do it with a regexp instead. This function is not used from 511 | # _version.py. 512 | keywords = {} 513 | try: 514 | f = open(versionfile_abs,"r") 515 | for line in f.readlines(): 516 | if line.strip().startswith("git_refnames ="): 517 | mo = re.search(r'=\s*"(.*)"', line) 518 | if mo: 519 | keywords["refnames"] = mo.group(1) 520 | if line.strip().startswith("git_full ="): 521 | mo = re.search(r'=\s*"(.*)"', line) 522 | if mo: 523 | keywords["full"] = mo.group(1) 524 | f.close() 525 | except EnvironmentError: 526 | pass 527 | return keywords 528 | 529 | def git_versions_from_keywords(keywords, tag_prefix, verbose=False): 530 | if not keywords: 531 | return {} # keyword-finding function failed to find keywords 532 | refnames = keywords["refnames"].strip() 533 | if refnames.startswith("$Format"): 534 | if verbose: 535 | print("keywords are unexpanded, not using") 536 | return {} # unexpanded, so not in an unpacked git-archive tarball 537 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 538 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 539 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 540 | TAG = "tag: " 541 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 542 | if not tags: 543 | # Either we're using git < 1.8.3, or there really are no tags. We use 544 | # a heuristic: assume all version tags have a digit. The old git %d 545 | # expansion behaves like git log --decorate=short and strips out the 546 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 547 | # between branches and tags. By ignoring refnames without digits, we 548 | # filter out many common branch names like "release" and 549 | # "stabilization", as well as "HEAD" and "master". 550 | tags = set([r for r in refs if re.search(r'\d', r)]) 551 | if verbose: 552 | print("discarding '%s', no digits" % ",".join(refs-tags)) 553 | if verbose: 554 | print("likely tags: %s" % ",".join(sorted(tags))) 555 | for ref in sorted(tags): 556 | # sorting will prefer e.g. "2.0" over "2.0rc1" 557 | if ref.startswith(tag_prefix): 558 | r = ref[len(tag_prefix):] 559 | if verbose: 560 | print("picking %s" % r) 561 | return { "version": r, 562 | "full": keywords["full"].strip() } 563 | # no suitable tags, so we use the full revision id 564 | if verbose: 565 | print("no suitable tags, using full revision id") 566 | return { "version": keywords["full"].strip(), 567 | "full": keywords["full"].strip() } 568 | 569 | 570 | def git_versions_from_vcs(tag_prefix, root, verbose=False): 571 | # this runs 'git' from the root of the source tree. This only gets called 572 | # if the git-archive 'subst' keywords were *not* expanded, and 573 | # _version.py hasn't already been rewritten with a short version string, 574 | # meaning we're inside a checked out source tree. 575 | 576 | if not os.path.exists(os.path.join(root, ".git")): 577 | if verbose: 578 | print("no .git in %s" % root) 579 | return {} 580 | 581 | GITS = ["git"] 582 | if sys.platform == "win32": 583 | GITS = ["git.cmd", "git.exe"] 584 | stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], 585 | cwd=root) 586 | if stdout is None: 587 | return {} 588 | if not stdout.startswith(tag_prefix): 589 | if verbose: 590 | print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) 591 | return {} 592 | tag = stdout[len(tag_prefix):] 593 | stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 594 | if stdout is None: 595 | return {} 596 | full = stdout.strip() 597 | if tag.endswith("-dirty"): 598 | full += "-dirty" 599 | return {"version": tag, "full": full} 600 | 601 | 602 | def do_vcs_install(manifest_in, versionfile_source, ipy): 603 | GITS = ["git"] 604 | if sys.platform == "win32": 605 | GITS = ["git.cmd", "git.exe"] 606 | files = [manifest_in, versionfile_source] 607 | if ipy: 608 | files.append(ipy) 609 | try: 610 | me = __file__ 611 | if me.endswith(".pyc") or me.endswith(".pyo"): 612 | me = os.path.splitext(me)[0] + ".py" 613 | versioneer_file = os.path.relpath(me) 614 | except NameError: 615 | versioneer_file = "versioneer.py" 616 | files.append(versioneer_file) 617 | present = False 618 | try: 619 | f = open(".gitattributes", "r") 620 | for line in f.readlines(): 621 | if line.strip().startswith(versionfile_source): 622 | if "export-subst" in line.strip().split()[1:]: 623 | present = True 624 | f.close() 625 | except EnvironmentError: 626 | pass 627 | if not present: 628 | f = open(".gitattributes", "a+") 629 | f.write("%s export-subst\n" % versionfile_source) 630 | f.close() 631 | files.append(".gitattributes") 632 | run_command(GITS, ["add", "--"] + files) 633 | 634 | def versions_from_parentdir(parentdir_prefix, root, verbose=False): 635 | # Source tarballs conventionally unpack into a directory that includes 636 | # both the project name and a version string. 637 | dirname = os.path.basename(root) 638 | if not dirname.startswith(parentdir_prefix): 639 | if verbose: 640 | print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % 641 | (root, dirname, parentdir_prefix)) 642 | return None 643 | return {"version": dirname[len(parentdir_prefix):], "full": ""} 644 | 645 | SHORT_VERSION_PY = """ 646 | # This file was generated by 'versioneer.py' (0.12) from 647 | # revision-control system data, or from the parent directory name of an 648 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 649 | # of this file. 650 | 651 | version_version = '%(version)s' 652 | version_full = '%(full)s' 653 | def get_versions(default={}, verbose=False): 654 | return {'version': version_version, 'full': version_full} 655 | 656 | """ 657 | 658 | DEFAULT = {"version": "unknown", "full": "unknown"} 659 | 660 | def versions_from_file(filename): 661 | versions = {} 662 | try: 663 | with open(filename) as f: 664 | for line in f.readlines(): 665 | mo = re.match("version_version = '([^']+)'", line) 666 | if mo: 667 | versions["version"] = mo.group(1) 668 | mo = re.match("version_full = '([^']+)'", line) 669 | if mo: 670 | versions["full"] = mo.group(1) 671 | except EnvironmentError: 672 | return {} 673 | 674 | return versions 675 | 676 | def write_to_version_file(filename, versions): 677 | with open(filename, "w") as f: 678 | f.write(SHORT_VERSION_PY % versions) 679 | 680 | print("set %s to '%s'" % (filename, versions["version"])) 681 | 682 | 683 | def get_root(): 684 | try: 685 | return os.path.dirname(os.path.abspath(__file__)) 686 | except NameError: 687 | return os.path.dirname(os.path.abspath(sys.argv[0])) 688 | 689 | def vcs_function(vcs, suffix): 690 | return getattr(sys.modules[__name__], '%s_%s' % (vcs, suffix), None) 691 | 692 | def get_versions(default=DEFAULT, verbose=False): 693 | # returns dict with two keys: 'version' and 'full' 694 | assert versionfile_source is not None, "please set versioneer.versionfile_source" 695 | assert tag_prefix is not None, "please set versioneer.tag_prefix" 696 | assert parentdir_prefix is not None, "please set versioneer.parentdir_prefix" 697 | assert VCS is not None, "please set versioneer.VCS" 698 | 699 | # I am in versioneer.py, which must live at the top of the source tree, 700 | # which we use to compute the root directory. py2exe/bbfreeze/non-CPython 701 | # don't have __file__, in which case we fall back to sys.argv[0] (which 702 | # ought to be the setup.py script). We prefer __file__ since that's more 703 | # robust in cases where setup.py was invoked in some weird way (e.g. pip) 704 | root = get_root() 705 | versionfile_abs = os.path.join(root, versionfile_source) 706 | 707 | # extract version from first of _version.py, VCS command (e.g. 'git 708 | # describe'), parentdir. This is meant to work for developers using a 709 | # source checkout, for users of a tarball created by 'setup.py sdist', 710 | # and for users of a tarball/zipball created by 'git archive' or github's 711 | # download-from-tag feature or the equivalent in other VCSes. 712 | 713 | get_keywords_f = vcs_function(VCS, "get_keywords") 714 | versions_from_keywords_f = vcs_function(VCS, "versions_from_keywords") 715 | if get_keywords_f and versions_from_keywords_f: 716 | vcs_keywords = get_keywords_f(versionfile_abs) 717 | ver = versions_from_keywords_f(vcs_keywords, tag_prefix) 718 | if ver: 719 | if verbose: print("got version from expanded keyword %s" % ver) 720 | return ver 721 | 722 | ver = versions_from_file(versionfile_abs) 723 | if ver: 724 | if verbose: print("got version from file %s %s" % (versionfile_abs,ver)) 725 | return ver 726 | 727 | versions_from_vcs_f = vcs_function(VCS, "versions_from_vcs") 728 | if versions_from_vcs_f: 729 | ver = versions_from_vcs_f(tag_prefix, root, verbose) 730 | if ver: 731 | if verbose: print("got version from VCS %s" % ver) 732 | return ver 733 | 734 | ver = versions_from_parentdir(parentdir_prefix, root, verbose) 735 | if ver: 736 | if verbose: print("got version from parentdir %s" % ver) 737 | return ver 738 | 739 | if verbose: print("got version from default %s" % default) 740 | return default 741 | 742 | def get_version(verbose=False): 743 | return get_versions(verbose=verbose)["version"] 744 | 745 | class cmd_version(Command): 746 | description = "report generated version string" 747 | user_options = [] 748 | boolean_options = [] 749 | def initialize_options(self): 750 | pass 751 | def finalize_options(self): 752 | pass 753 | def run(self): 754 | ver = get_version(verbose=True) 755 | print("Version is currently: %s" % ver) 756 | 757 | 758 | class cmd_build(_build): 759 | def run(self): 760 | versions = get_versions(verbose=True) 761 | _build.run(self) 762 | # now locate _version.py in the new build/ directory and replace it 763 | # with an updated value 764 | if versionfile_build: 765 | target_versionfile = os.path.join(self.build_lib, versionfile_build) 766 | print("UPDATING %s" % target_versionfile) 767 | os.unlink(target_versionfile) 768 | with open(target_versionfile, "w") as f: 769 | f.write(SHORT_VERSION_PY % versions) 770 | 771 | if 'cx_Freeze' in sys.modules: # cx_freeze enabled? 772 | from cx_Freeze.dist import build_exe as _build_exe 773 | 774 | class cmd_build_exe(_build_exe): 775 | def run(self): 776 | versions = get_versions(verbose=True) 777 | target_versionfile = versionfile_source 778 | print("UPDATING %s" % target_versionfile) 779 | os.unlink(target_versionfile) 780 | with open(target_versionfile, "w") as f: 781 | f.write(SHORT_VERSION_PY % versions) 782 | 783 | _build_exe.run(self) 784 | os.unlink(target_versionfile) 785 | with open(versionfile_source, "w") as f: 786 | assert VCS is not None, "please set versioneer.VCS" 787 | LONG = LONG_VERSION_PY[VCS] 788 | f.write(LONG % {"DOLLAR": "$", 789 | "TAG_PREFIX": tag_prefix, 790 | "PARENTDIR_PREFIX": parentdir_prefix, 791 | "VERSIONFILE_SOURCE": versionfile_source, 792 | }) 793 | 794 | class cmd_sdist(_sdist): 795 | def run(self): 796 | versions = get_versions(verbose=True) 797 | self._versioneer_generated_versions = versions 798 | # unless we update this, the command will keep using the old version 799 | self.distribution.metadata.version = versions["version"] 800 | return _sdist.run(self) 801 | 802 | def make_release_tree(self, base_dir, files): 803 | _sdist.make_release_tree(self, base_dir, files) 804 | # now locate _version.py in the new base_dir directory (remembering 805 | # that it may be a hardlink) and replace it with an updated value 806 | target_versionfile = os.path.join(base_dir, versionfile_source) 807 | print("UPDATING %s" % target_versionfile) 808 | os.unlink(target_versionfile) 809 | with open(target_versionfile, "w") as f: 810 | f.write(SHORT_VERSION_PY % self._versioneer_generated_versions) 811 | 812 | INIT_PY_SNIPPET = """ 813 | from ._version import get_versions 814 | __version__ = get_versions()['version'] 815 | del get_versions 816 | """ 817 | 818 | class cmd_update_files(Command): 819 | description = "install/upgrade Versioneer files: __init__.py SRC/_version.py" 820 | user_options = [] 821 | boolean_options = [] 822 | def initialize_options(self): 823 | pass 824 | def finalize_options(self): 825 | pass 826 | def run(self): 827 | print(" creating %s" % versionfile_source) 828 | with open(versionfile_source, "w") as f: 829 | assert VCS is not None, "please set versioneer.VCS" 830 | LONG = LONG_VERSION_PY[VCS] 831 | f.write(LONG % {"DOLLAR": "$", 832 | "TAG_PREFIX": tag_prefix, 833 | "PARENTDIR_PREFIX": parentdir_prefix, 834 | "VERSIONFILE_SOURCE": versionfile_source, 835 | }) 836 | 837 | ipy = os.path.join(os.path.dirname(versionfile_source), "__init__.py") 838 | if os.path.exists(ipy): 839 | try: 840 | with open(ipy, "r") as f: 841 | old = f.read() 842 | except EnvironmentError: 843 | old = "" 844 | if INIT_PY_SNIPPET not in old: 845 | print(" appending to %s" % ipy) 846 | with open(ipy, "a") as f: 847 | f.write(INIT_PY_SNIPPET) 848 | else: 849 | print(" %s unmodified" % ipy) 850 | else: 851 | print(" %s doesn't exist, ok" % ipy) 852 | ipy = None 853 | 854 | # Make sure both the top-level "versioneer.py" and versionfile_source 855 | # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so 856 | # they'll be copied into source distributions. Pip won't be able to 857 | # install the package without this. 858 | manifest_in = os.path.join(get_root(), "MANIFEST.in") 859 | simple_includes = set() 860 | try: 861 | with open(manifest_in, "r") as f: 862 | for line in f: 863 | if line.startswith("include "): 864 | for include in line.split()[1:]: 865 | simple_includes.add(include) 866 | except EnvironmentError: 867 | pass 868 | # That doesn't cover everything MANIFEST.in can do 869 | # (http://docs.python.org/2/distutils/sourcedist.html#commands), so 870 | # it might give some false negatives. Appending redundant 'include' 871 | # lines is safe, though. 872 | if "versioneer.py" not in simple_includes: 873 | print(" appending 'versioneer.py' to MANIFEST.in") 874 | with open(manifest_in, "a") as f: 875 | f.write("include versioneer.py\n") 876 | else: 877 | print(" 'versioneer.py' already in MANIFEST.in") 878 | if versionfile_source not in simple_includes: 879 | print(" appending versionfile_source ('%s') to MANIFEST.in" % 880 | versionfile_source) 881 | with open(manifest_in, "a") as f: 882 | f.write("include %s\n" % versionfile_source) 883 | else: 884 | print(" versionfile_source already in MANIFEST.in") 885 | 886 | # Make VCS-specific changes. For git, this means creating/changing 887 | # .gitattributes to mark _version.py for export-time keyword 888 | # substitution. 889 | do_vcs_install(manifest_in, versionfile_source, ipy) 890 | 891 | def get_cmdclass(): 892 | cmds = {'version': cmd_version, 893 | 'versioneer': cmd_update_files, 894 | 'build': cmd_build, 895 | 'sdist': cmd_sdist, 896 | } 897 | if 'cx_Freeze' in sys.modules: # cx_freeze enabled? 898 | cmds['build_exe'] = cmd_build_exe 899 | del cmds['build'] 900 | 901 | return cmds 902 | --------------------------------------------------------------------------------