├── .gitignore ├── COPYING ├── LICENSE_Apache2.0 ├── LICENSE_GPLv2 ├── Makefile ├── README.md ├── examples ├── base.py ├── clone.py ├── clone_bare.py ├── commit.py ├── complete.py ├── config.py ├── diff.py ├── fork.py ├── mv.py ├── paths.py ├── pull.py ├── push.py ├── server.py ├── status.py └── versions.py ├── gittle ├── __init__.py ├── auth.py ├── exceptions.py ├── gittle.py ├── server.py └── utils │ ├── __init__.py │ ├── git.py │ ├── paths.py │ └── urls.py ├── setup.cfg ├── setup.py └── sitecustomize.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # ctags 39 | /tags 40 | 41 | # gvim 42 | .*.swp 43 | *.bak 44 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | As of July 2nd, 2014, we have made a change to the gittle 2 | licensing. 3 | 4 | gittle is now dual-licensed as Apache 2.0 and GPLv2. Please 5 | see the files LICENSE_Apache2.0 and LICENSE_GPLv2 for the 6 | gory legalese of the respective licenses. 7 | 8 | See: https://github.com/FriendCode/gittle/issues/24 9 | for the backstory behind this change. In short, our 10 | objective was simply to license gittle in as convenient 11 | and efficacious a manner as possible for its consumers. 12 | 13 | If you have any questions about this, drop us a line. We'll 14 | don our best lawyer costumes, and do what we can to help. 15 | 16 | When contributing pull requests to Gittle, please remember 17 | to add yourself as a contributor at the top of any files 18 | you modify. 19 | 20 | Thanks! 21 | 22 | -gmt & the gittle coders 23 | -------------------------------------------------------------------------------- /LICENSE_Apache2.0: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright [yyyy] [name of copyright owner] 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /LICENSE_GPLv2: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf *.egg-info 3 | rm -rf build 4 | rm -rf dist 5 | 6 | tag-release: 7 | sed -i "0,/version_string/c\version_string = '$(v)'" setup.py 8 | git add setup.py && git commit -m "Automated version bump to $(v)" && git push 9 | git tag -a release/$(v) -m "Automated release of $(v) via Makefile" && git push origin --tags 10 | 11 | package: 12 | rm -rf build 13 | python setup.py clean 14 | python setup.py build sdist bdist_wheel 15 | 16 | distribute: 17 | twine upload -s dist/gittle-$(v)* 18 | 19 | release: 20 | $(MAKE) tag-release 21 | $(MAKE) package 22 | $(MAKE) distribute 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gittle - Pythonic Git for Humans 2 | 3 | Gittle is a high-level pure-python git library. 4 | It builds upon dulwich which provides most of the low-level machinery 5 | 6 | ## Install it 7 | 8 | pip install gittle 9 | 10 | ## Examples : 11 | 12 | ### Clone a repository 13 | 14 | ```python 15 | from gittle import Gittle 16 | 17 | repo_path = '/tmp/gittle_bare' 18 | repo_url = 'git://github.com/FriendCode/gittle.git' 19 | 20 | repo = Gittle.clone(repo_url, repo_path) 21 | ``` 22 | 23 | With authentication (see Authentication section for more information) : 24 | 25 | ```python 26 | auth = GittleAuth(pkey=key) 27 | Gittle.clone(repo_url, repo_path, auth=auth) 28 | ``` 29 | 30 | Or clone bare repository (no working directory) : 31 | 32 | ```python 33 | repo = Gittle.clone(repo_url, repo_path, bare=True) 34 | ``` 35 | 36 | ### Init repository from a path 37 | 38 | ```python 39 | repo = Gittle.init(path) 40 | ``` 41 | 42 | ### Get repository information 43 | 44 | ```python 45 | # Get list of objects 46 | repo.commits 47 | 48 | # Get list of branches 49 | repo.branches 50 | 51 | # Get list of modified files (in current working directory) 52 | repo.modified_files 53 | 54 | # Get diff between latest commits 55 | repo.diff('HEAD', 'HEAD~1') 56 | ``` 57 | 58 | ### Commit 59 | 60 | ```python 61 | # Stage single file 62 | repo.stage('file.txt') 63 | 64 | # Stage multiple files 65 | repo.stage(['other1.txt', 'other2.txt']) 66 | 67 | # Do the commit 68 | repo.commit(name="Samy Pesse", email="samy@friendco.de", message="This is a commit") 69 | ``` 70 | 71 | ### Pull 72 | 73 | ```python 74 | repo = Gittle(repo_path, origin_uri=repo_url) 75 | 76 | # Authentication with RSA private key 77 | key_file = open('/Users/Me/keys/rsa/private_rsa') 78 | repo.auth(pkey=key_file) 79 | 80 | # Do pull 81 | repo.pull() 82 | ``` 83 | 84 | ### Push 85 | 86 | ```python 87 | repo = Gittle(repo_path, origin_uri=repo_url) 88 | 89 | # Authentication with RSA private key 90 | key_file = open('/Users/Me/keys/rsa/private_rsa') 91 | repo.auth(pkey=key_file) 92 | 93 | # Do push 94 | repo.push() 95 | ``` 96 | 97 | ### Authentication for remote operations 98 | 99 | ```python 100 | # With a key 101 | key_file = open('/Users/Me/keys/rsa/private_rsa') 102 | repo.auth(pkey=key_file) 103 | 104 | # With username and password 105 | repo.auth(username="your_name", password="your_password") 106 | ``` 107 | 108 | ### Branch 109 | 110 | ```python 111 | # Create branch off master 112 | repo.create_branch('dev', 'master') 113 | 114 | # Checkout the branch 115 | repo.switch_branch('dev') 116 | 117 | # Create an empty branch (like 'git checkout --orphan') 118 | repo.create_orphan_branch('NewBranchName') 119 | 120 | # Print a list of branches 121 | print(repo.branches) 122 | 123 | # Remove a branch 124 | repo.remove_branch('dev') 125 | 126 | # Print a list of branches 127 | print(repo.branches) 128 | ``` 129 | 130 | ### Get file version 131 | 132 | ```python 133 | versions = repo.get_file_versions('gittle/gittle.py') 134 | print("Found %d versions out of a total of %d commits" % (len(versions), repo.commit_count())) 135 | ``` 136 | 137 | ### Get list of modified files (in current working directory) 138 | 139 | ```python 140 | repo.modified_files 141 | ``` 142 | 143 | ### Count number of commits 144 | 145 | ```python 146 | repo.commit_count 147 | ``` 148 | 149 | ### Get information for commits 150 | 151 | List commits : 152 | 153 | ```python 154 | # Get 20 first commits 155 | repo.commit_info(start=0, end=20) 156 | ``` 157 | 158 | With a given commit : 159 | 160 | ```python 161 | commit = "a2105a0d528bf770021de874baf72ce36f6c3ccc" 162 | ``` 163 | 164 | Diff with another commit : 165 | 166 | ```python 167 | old_commit = repo.get_previous_commit(commit, n=1) 168 | print repo.diff(commit, old_commit) 169 | ``` 170 | 171 | Explore commit files using : 172 | 173 | ```python 174 | commit = "a2105a0d528bf770021de874baf72ce36f6c3ccc" 175 | 176 | # Files tree 177 | print repo.commit_tree(commit) 178 | 179 | # List files in a subpath 180 | print repo.commit_ls(commit, "testdir") 181 | 182 | # Read a file 183 | print repo.commit_file(commit, "testdir/test.txt") 184 | ``` 185 | 186 | ### Create a GIT server 187 | 188 | ```python 189 | from gittle import GitServer 190 | 191 | # Read only 192 | GitServer('/', 'localhost').serve_forever() 193 | 194 | # Read/Write 195 | GitServer('/', 'localhost', perm='rw').serve_forever() 196 | ``` 197 | 198 | ## Why implement Git in Python ? 199 | 200 | ### NEED FOR AWESOMENESS : 201 | - Git is Awesome 202 | - Python is Awesome 203 | - Automating Git isn't so Awesome 204 | 205 | ### TO SOLVE MY OWN PROBLEMS AT FRIENDCODE : 206 | - Automate git repo management (push/pull, commit, etc ...) 207 | - Scriptable and usable from Python 208 | - Easy to use & good interoperability in a SOA environment 209 | 210 | ### USE IT FOR : 211 | - Local 212 | - [X] Common git operations (add, rm, mv, commit, log) 213 | - [X] Branch operations (creating, switching, deleting) 214 | - Remote 215 | - [X] Fetching 216 | - [X] Pushing 217 | - [X] Pulling (needs merging) 218 | - Merging 219 | - [-] Fast forward 220 | - [-] Recursive 221 | - [-] Merge branches 222 | - Diff 223 | - [X] Filter binary files 224 | 225 | # Building and uploading to PyPi 226 | 227 | ``` 228 | python setup.py sdist bdist_egg upload 229 | ``` 230 | -------------------------------------------------------------------------------- /examples/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Copyright 2013 Aaron O'Mullan 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 6 | # License, Version 2.0. See the COPYING file for further details. 7 | 8 | from gittle import Gittle 9 | 10 | 11 | def random_char(): 12 | while True: 13 | yield random.choice(string.ascii_letters) 14 | 15 | TMP_FILES = { 16 | 'local': { 17 | 'a' 18 | 'b': random_content(), 19 | }, 20 | 'remote': { 21 | 22 | } 23 | 24 | } 25 | 26 | 27 | GIT_PATHS = { 28 | 'remote': '/tmp/gittle_test_remote', 29 | 'local': '/tmp/gittle_test_local', 30 | } 31 | 32 | 33 | 34 | def random_content(length=512): 35 | return ''.join([ 36 | random.choice(string.ascii_letters) 37 | for x in xrange(length) 38 | ]) 39 | 40 | 41 | def create_remote(): 42 | remote = Gittle.init(TMP_REMOTE_GIT) 43 | 44 | 45 | def create_local(): 46 | local = Gittle.init(TMP_LOCAL_GIT) 47 | """ 48 | -------------------------------------------------------------------------------- /examples/clone.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | repo_path = '/tmp/gittle_bare' 4 | repo_url = 'git://github.com/AaronO/dulwich.git' 5 | 6 | repo = Gittle.clone(repo_url, repo_path) 7 | 8 | print((repo.tracked_files)) 9 | -------------------------------------------------------------------------------- /examples/clone_bare.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | repo_path = '/tmp/gittle_bare' 4 | repo_url = 'git://github.com/AaronO/dulwich.git' 5 | 6 | repo = Gittle.clone_bare(repo_url, repo_path) 7 | 8 | print((repo.tracked_files)) 9 | -------------------------------------------------------------------------------- /examples/commit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | # Copyright 2013 Aaron O'Mullan 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 7 | # License, Version 2.0. See the COPYING file for further details. 8 | 9 | import os 10 | from gittle import Gittle 11 | from tempfile import mkdtemp 12 | 13 | path = mkdtemp() 14 | fn = 'test.txt' 15 | filename = os.path.join(path, fn) 16 | 17 | name = 'Samy Pessé' 18 | email = 'samypesse@gmail.com' 19 | message = "C'est beau là bas" 20 | 21 | 22 | def create_file(): 23 | fd = open(filename, 'w+') 24 | fd.write('blabla\n BOOM BOOM\n à la montagne') 25 | fd.close() 26 | 27 | repo = Gittle.init(path) 28 | create_file() 29 | 30 | repo.stage(fn) 31 | repo.commit(name=name, email=email, message=message) 32 | 33 | 34 | print(('COMMIT_INFO =', repo.commit_info())) 35 | 36 | print(('PATH =', path)) 37 | -------------------------------------------------------------------------------- /examples/complete.py: -------------------------------------------------------------------------------- 1 | # Samy Pessé 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | from gittle import Gittle 8 | 9 | path = '/tmp/gittle_bare' 10 | 11 | # Clone repository 12 | repo = Gittle.clone('git://github.com/FriendCode/gittle.git', path) 13 | 14 | # Information 15 | print("Branches :") 16 | print(repo.branches) 17 | print("Commits :") 18 | print(repo.commit_count) 19 | 20 | # Commiting 21 | fn = 'test.txt' 22 | filename = os.path.join(path, fn) 23 | 24 | # Create a new file 25 | fd = open(filename, 'w+') 26 | fd.write('My file commited using Gittle') 27 | fd.close() 28 | 29 | # Stage file 30 | repo.stage(fn) 31 | 32 | # Do commit 33 | repo.commit(name='Samy Pessé', email='samypesse@gmail.com', message="This is a commit") 34 | 35 | # Commit info 36 | print("Commit : ", repo.commit_info()) 37 | 38 | # Auth for pushing 39 | repo.auth(pkey=open("private_key")) 40 | 41 | # Push 42 | repo.push() 43 | -------------------------------------------------------------------------------- /examples/config.py: -------------------------------------------------------------------------------- 1 | # Constants 2 | repo_path = '/Users/aaron/git/gittle' 3 | repo_url = 'git@friendco.de:friendcode/gittle.git' 4 | 5 | # RSA private key 6 | key_file = open('/Users/aaron/git/friendcode-conf/rsa/friendcode_rsa') -------------------------------------------------------------------------------- /examples/diff.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | repo = Gittle('.') 4 | 5 | lastest = [ 6 | info['sha'] 7 | for info in repo.commit_info()[1:3] 8 | ] 9 | 10 | print((repo.diff(*lastest, diff_type='classic'))) 11 | 12 | print(""" 13 | 14 | Last Diff 15 | 16 | """) 17 | 18 | 19 | print((list(repo.diff('HEAD')))) 20 | -------------------------------------------------------------------------------- /examples/fork.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendCode/gittle/de0dfb704bf5ab87642b45d70a16a1a5f045e611/examples/fork.py -------------------------------------------------------------------------------- /examples/mv.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | from config import repo_path 4 | 5 | g = Gittle(repo_path) 6 | 7 | g.mv([ 8 | ('setup.py', 'new.py'), 9 | ]) 10 | -------------------------------------------------------------------------------- /examples/paths.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | import os 8 | from functools import partial 9 | 10 | from gittle import Gittle 11 | 12 | BASE_DIR = '/Users/aaron/git/' 13 | absbase = partial(os.path.join, BASE_DIR) 14 | 15 | TRIES = 1 16 | PATHS = list(map(absbase, [ 17 | 'gittle/', 18 | 'loadfire/', 19 | ])) 20 | 21 | 22 | def paths_exists(repo): 23 | tracked_files = repo.tracked_files 24 | 25 | return all([ 26 | os.path.exists(path) 27 | for path in [ 28 | repo.abspath(repopath) 29 | for repopath in tracked_files 30 | ] 31 | ]) 32 | 33 | 34 | def changed_entires(repo): 35 | return repo._changed_entries() 36 | 37 | TESTS = ( 38 | paths_exists, 39 | changed_entires, 40 | ) 41 | 42 | 43 | def test_repo(repo_path): 44 | repo = Gittle(repo_path) 45 | return all([ 46 | test(repo) 47 | for test in TESTS 48 | ]) 49 | 50 | 51 | def main(): 52 | paths = PATHS * TRIES 53 | for path in paths: 54 | print(('Testing : %s' % path)) 55 | test_repo(path) 56 | 57 | 58 | if __name__ == '__main__': 59 | main() 60 | -------------------------------------------------------------------------------- /examples/pull.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | from config import repo_path, repo_url, key_file 4 | 5 | # Gittle repo 6 | g = Gittle(repo_path, origin_uri=repo_url) 7 | 8 | # Authentication 9 | g.auth(pkey=key_file) 10 | 11 | # Do pull 12 | g.pull() 13 | -------------------------------------------------------------------------------- /examples/push.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | from config import repo_path, repo_url, key_file 4 | 5 | # Gittle repo 6 | g = Gittle(repo_path, origin_uri=repo_url) 7 | 8 | # Authentication 9 | g.auth(pkey=key_file) 10 | 11 | # Do push 12 | g.push() 13 | -------------------------------------------------------------------------------- /examples/server.py: -------------------------------------------------------------------------------- 1 | from gittle import GitServer 2 | 3 | server = GitServer('/', 'localhost') 4 | server.serve_forever() 5 | -------------------------------------------------------------------------------- /examples/status.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | from gittle import Gittle 8 | 9 | from config import repo_path 10 | 11 | g = Gittle(repo_path) 12 | 13 | 14 | def print_files(group_name, paths): 15 | if not paths: 16 | return 17 | sorted_paths = sorted(paths) 18 | print(("\n%s :" % group_name)) 19 | print(('\n'.join(sorted_paths))) 20 | 21 | #print_files('Changes not staged for commit', g.modified_unstaged_files) 22 | #print_files('Changes staged for commit', g.modified_staged_files) 23 | #print_files('Ignored files', g.ignored_files) 24 | print_files('Modified files', g.modified_files) 25 | print_files('Untracked Files', g.untracked_files) 26 | print_files('Tracked Files', g.tracked_files) 27 | print_files('Trackable Files', g.trackable_files) 28 | -------------------------------------------------------------------------------- /examples/versions.py: -------------------------------------------------------------------------------- 1 | from gittle import Gittle 2 | 3 | repo = Gittle('.') 4 | versions = repo.get_file_versions('gittle/gittle.py') 5 | 6 | print(("Found %d versions out of a total of %d commits" % (len(versions), repo.commit_count()))) 7 | -------------------------------------------------------------------------------- /gittle/__init__.py: -------------------------------------------------------------------------------- 1 | from . import utils 2 | from .gittle import Gittle 3 | from .server import GitServer 4 | from .exceptions import * 5 | from .auth import GittleAuth -------------------------------------------------------------------------------- /gittle/auth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | # Python imports 8 | import os 9 | try: 10 | # Try importing the faster version 11 | from io import StringIO 12 | except ImportError: 13 | # Fallback to pure python if not available 14 | from io import StringIO 15 | 16 | 17 | # Paramiko imports 18 | try: 19 | import paramiko 20 | HAS_PARAMIKO = True 21 | except ImportError: 22 | HAS_PARAMIKO = False 23 | 24 | # Local imports 25 | from .exceptions import InvalidRSAKey 26 | 27 | 28 | # Exports 29 | __all__ = ('GittleAuth',) 30 | 31 | if os.sys.version_info.major > 2 or (os.sys.version_info.major == 2 and os.sys.version_info.minor < 7): 32 | str = str 33 | 34 | def get_pkey_file(pkey): 35 | if isinstance(pkey, str): 36 | if os.path.exists(pkey): 37 | pkey_file = open(pkey) 38 | else: 39 | # Raw data 40 | pkey_file = StringIO(pkey) 41 | else: 42 | return pkey 43 | return pkey_file 44 | 45 | 46 | class GittleAuth(object): 47 | KWARG_KEYS = ( 48 | 'username', 49 | 'password', 50 | 'pkey', 51 | 'look_for_keys', 52 | 'allow_agent' 53 | ) 54 | 55 | def __init__(self, username=None, password=None, pkey=None, look_for_keys=None, allow_agent=None): 56 | self.username = username 57 | self.password = password 58 | self.allow_agent = allow_agent 59 | self.look_for_keys = look_for_keys 60 | 61 | self.pkey = self.setup_pkey(pkey) 62 | 63 | def setup_pkey(self, pkey): 64 | pkey_file = get_pkey_file(pkey) 65 | if not pkey_file: 66 | return None 67 | if HAS_PARAMIKO: 68 | return paramiko.RSAKey.from_private_key(pkey_file) 69 | else: 70 | raise InvalidRSAKey('Requires paramiko to build RSA key') 71 | 72 | @property 73 | def can_password(self): 74 | return self.username and self.password 75 | 76 | @property 77 | def can_pkey(self): 78 | return not self.pkey is None 79 | 80 | @property 81 | def could_other(self): 82 | return self.look_for_keys or self.allow_agent 83 | 84 | def can_auth(self): 85 | return any([ 86 | self.can_password, 87 | self.can_pkey, 88 | self.could_other 89 | ]) 90 | 91 | def kwargs(self): 92 | kwargs = { 93 | key: getattr(self, key) 94 | for key in self.KWARG_KEYS 95 | if getattr(self, key, None) 96 | } 97 | return kwargs 98 | -------------------------------------------------------------------------------- /gittle/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidRemoteUrl(Exception): 2 | """The url provided for the remote service is invalid""" 3 | pass 4 | 5 | class InvalidRSAKey(Exception): 6 | """Can't generate key ...""" 7 | pass 8 | -------------------------------------------------------------------------------- /gittle/gittle.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2014 Aaron O'Mullan 2 | # Copyright 2014 Christopher Corley 3 | # Copyright 2014 Gregory M. Turner 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 7 | # License, Version 2.0. See the COPYING file for further details. 8 | 9 | # From the future 10 | 11 | 12 | # Python imports 13 | import os 14 | import copy 15 | import logging 16 | from hashlib import sha1 17 | from shutil import rmtree 18 | from functools import partial, wraps 19 | 20 | # Dulwich imports 21 | from dulwich.repo import Repo as DulwichRepo 22 | from dulwich.client import get_transport_and_path 23 | from dulwich.index import build_index_from_tree, changes_from_tree 24 | from dulwich.objects import Tree, Blob 25 | from dulwich.server import update_server_info 26 | from dulwich.refs import SYMREF 27 | from dulwich.errors import NotGitRepository 28 | 29 | # Funky imports 30 | import funky 31 | 32 | # Local imports 33 | from gittle.auth import GittleAuth 34 | from gittle.exceptions import InvalidRemoteUrl 35 | from gittle import utils 36 | 37 | 38 | # Exports 39 | __all__ = ('Gittle',) 40 | 41 | if os.sys.version_info.major > 2 or (os.sys.version_info.major == 2 and os.sys.version_info.minor < 7): 42 | str = str 43 | 44 | # Guarantee that a diretory exists 45 | def mkdir_safe(path): 46 | if path and not(os.path.exists(path)): 47 | os.makedirs(path) 48 | return path 49 | 50 | 51 | 52 | # Useful decorators 53 | # A better way to do this in the future would maybe to use Mixins 54 | def working_only(method): 55 | @wraps(method) 56 | def f(self, *args, **kwargs): 57 | assert self.is_working, "%s can not be called on a bare repository" % method.__name__ 58 | return method(self, *args, **kwargs) 59 | return f 60 | 61 | 62 | def bare_only(method): 63 | @wraps(method) 64 | def f(self, *args, **kwargs): 65 | assert self.is_bare, "%s can not be called on a working repository" % method.__name__ 66 | return method(self, *args, **kwargs) 67 | return f 68 | 69 | 70 | class Gittle(object): 71 | """All paths used in Gittle external methods must be paths relative to the git repository 72 | """ 73 | DEFAULT_COMMIT = 'HEAD' 74 | DEFAULT_BRANCH = 'master' 75 | DEFAULT_REMOTE = 'origin' 76 | DEFAULT_MESSAGE = '**No Message**' 77 | DEFAULT_USER_INFO = { 78 | 'name': None, 79 | 'email': None, 80 | } 81 | 82 | DIFF_FUNCTIONS = { 83 | 'classic': utils.git.classic_tree_diff, 84 | 'dict': utils.git.dict_tree_diff, 85 | 'changes': utils.git.dict_tree_diff 86 | } 87 | DEFAULT_DIFF_TYPE = 'dict' 88 | 89 | HIDDEN_REGEXES = [ 90 | # Hide git directory 91 | r'.*\/\.git\/.*', 92 | ] 93 | 94 | # References 95 | REFS_BRANCHES = 'refs/heads/' 96 | REFS_REMOTES = 'refs/remotes/' 97 | REFS_TAGS = 'refs/tags/' 98 | 99 | # Name pattern truths 100 | # Used for detecting if files are : 101 | # - deleted 102 | # - added 103 | # - changed 104 | PATTERN_ADDED = (False, True) 105 | PATTERN_REMOVED = (True, False) 106 | PATTERN_MODIFIED = (True, True) 107 | 108 | # Permissions 109 | MODE_DIRECTORY = 0o40000 # Used to tell if a tree entry is a directory 110 | 111 | # Tree depth 112 | MAX_TREE_DEPTH = 1000 113 | 114 | # Acceptable Root paths 115 | ROOT_PATHS = (os.path.curdir, os.path.sep) 116 | 117 | def __init__(self, repo_or_path, origin_uri=None, auth=None, report_activity=None, *args, **kwargs): 118 | if isinstance(repo_or_path, DulwichRepo): 119 | self.repo = repo_or_path 120 | elif isinstance(repo_or_path, Gittle): 121 | self.repo = DulwichRepo(repo_or_path.path) 122 | elif isinstance(repo_or_path, str): 123 | path = os.path.abspath(repo_or_path) 124 | self.repo = DulwichRepo(path) 125 | else: 126 | logging.warning('Repo is of type %s' % type(repo_or_path)) 127 | raise Exception('Gittle must be initialized with either a dulwich repository or a string to the path') 128 | 129 | # Set path 130 | self.path = self.repo.path 131 | 132 | # The remote url 133 | self.origin_uri = origin_uri 134 | 135 | # Report client activty 136 | self._report_activity = report_activity 137 | 138 | # Build ignore filter 139 | self.hidden_regexes = copy.copy(self.HIDDEN_REGEXES) 140 | self.hidden_regexes.extend(self._get_ignore_regexes()) 141 | self.ignore_filter = utils.paths.path_filter_regex(self.hidden_regexes) 142 | self.filters = [ 143 | self.ignore_filter, 144 | ] 145 | 146 | # Get authenticator 147 | if auth: 148 | self.authenticator = auth 149 | else: 150 | self.auth(*args, **kwargs) 151 | 152 | def report_activity(self, *args, **kwargs): 153 | if not self._report_activity: 154 | return 155 | return self._report_activity(*args, **kwargs) 156 | 157 | def _format_author(self, name, email): 158 | return "%s <%s>" % (name, email) 159 | 160 | def _format_userinfo(self, userinfo): 161 | name = userinfo.get('name') 162 | email = userinfo.get('email') 163 | if name and email: 164 | return self._format_author(name, email) 165 | return None 166 | 167 | def _format_ref(self, base, extra): 168 | return ''.join([base, extra]) 169 | 170 | def _format_ref_branch(self, branch_name): 171 | return self._format_ref(self.REFS_BRANCHES, branch_name) 172 | 173 | def _format_ref_remote(self, remote_name): 174 | return self._format_ref(self.REFS_REMOTES, remote_name) 175 | 176 | def _format_ref_tag(self, tag_name): 177 | return self._format_ref(self.REFS_TAGS, tag_name) 178 | 179 | @property 180 | def head(self): 181 | """Return SHA of the current HEAD 182 | """ 183 | return self.repo.head() 184 | 185 | @property 186 | def is_bare(self): 187 | """Bare repositories have no working directories or indexes 188 | """ 189 | return self.repo.bare 190 | 191 | @property 192 | def is_working(self): 193 | return not(self.is_bare) 194 | 195 | def has_index(self): 196 | """Opposite of is_bare 197 | """ 198 | return self.repo.has_index() 199 | 200 | @property 201 | def has_commits(self): 202 | """ 203 | If the repository has no HEAD we consider that is has no commits 204 | """ 205 | try: 206 | self.repo.head() 207 | except KeyError: 208 | return False 209 | return True 210 | 211 | def ref_walker(self, ref=None): 212 | """ 213 | Very simple, basic walker 214 | """ 215 | ref = ref or 'HEAD' 216 | sha = self._commit_sha(ref) 217 | for entry in self.repo.get_walker(sha): 218 | yield entry.commit 219 | 220 | def branch_walker(self, branch): 221 | branch = branch or self.active_branch 222 | ref = self._format_ref_branch(branch) 223 | return self.ref_walker(ref) 224 | 225 | def commit_info(self, start=0, end=None, branch=None): 226 | """Return a generator of commits with all their attached information 227 | """ 228 | if not self.has_commits: 229 | return [] 230 | commits = [utils.git.commit_info(entry) for entry in self.branch_walker(branch)] 231 | if not end: 232 | return commits 233 | return commits[start:end] 234 | 235 | 236 | @funky.uniquify 237 | def recent_contributors(self, n=None, branch=None): 238 | n = n or 10 239 | return funky.pluck(self.commit_info(end=n, branch=branch), 'author') 240 | 241 | @property 242 | def commit_count(self): 243 | try: 244 | return len(self.ref_walker()) 245 | except KeyError: 246 | return 0 247 | 248 | def commits(self): 249 | """Return a list of SHAs for all the concerned commits 250 | """ 251 | return [commit['sha'] for commit in self.commit_info()] 252 | 253 | @property 254 | def git_dir(self): 255 | return self.repo.controldir() 256 | 257 | def auth(self, *args, **kwargs): 258 | self.authenticator = GittleAuth(*args, **kwargs) 259 | return self.authenticator 260 | 261 | # Generate a branch selector (used for pushing) 262 | def _wants_branch(self, branch_name=None): 263 | branch_name = branch_name or self.active_branch 264 | refs_key = self._format_ref_branch(branch_name) 265 | sha = self.branches[branch_name] 266 | 267 | def wants_func(old): 268 | refs_key = self._format_ref_branch(branch_name) 269 | return { 270 | refs_key: sha 271 | } 272 | return wants_func 273 | 274 | def _get_ignore_regexes(self): 275 | gitignore_filename = os.path.join(self.path, '.gitignore') 276 | if not os.path.exists(gitignore_filename): 277 | return [] 278 | lines = open(gitignore_filename).readlines() 279 | globers = [line.rstrip() for line in lines] 280 | return utils.paths.globers_to_regex(globers) 281 | 282 | # Get the absolute path for a file in the git repo 283 | def abspath(self, repo_file): 284 | return os.path.abspath( 285 | os.path.join(self.path, repo_file) 286 | ) 287 | 288 | # Get the relative path from the absolute path 289 | def relpath(self, abspath): 290 | return os.path.relpath(abspath, self.path) 291 | 292 | @property 293 | def last_commit(self): 294 | return self[self.repo.head()] 295 | 296 | @property 297 | def index(self): 298 | return self.repo.open_index() 299 | 300 | @classmethod 301 | def init(cls, path, bare=None, *args, **kwargs): 302 | """Initialize a repository""" 303 | mkdir_safe(path) 304 | 305 | # Constructor to use 306 | if bare: 307 | constructor = DulwichRepo.init_bare 308 | else: 309 | constructor = DulwichRepo.init 310 | 311 | # Create dulwich repo 312 | repo = constructor(path) 313 | 314 | # Create Gittle repo 315 | return cls(repo, *args, **kwargs) 316 | 317 | @classmethod 318 | def init_bare(cls, *args, **kwargs): 319 | kwargs.setdefault('bare', True) 320 | return cls.init(*args, **kwargs) 321 | 322 | @classmethod 323 | def is_repo(cls, path): 324 | """Returns True if path is a git repository, False if it is not""" 325 | try: 326 | repo = Gittle(path) 327 | except NotGitRepository: 328 | return False 329 | else: 330 | return True 331 | 332 | def get_client(self, origin_uri=None, **kwargs): 333 | # Get the remote URL 334 | origin_uri = origin_uri or self.origin_uri 335 | 336 | # Fail if inexistant 337 | if not origin_uri: 338 | raise InvalidRemoteUrl() 339 | 340 | client_kwargs = {} 341 | auth_kwargs = self.authenticator.kwargs() 342 | 343 | client_kwargs.update(auth_kwargs) 344 | client_kwargs.update(kwargs) 345 | client_kwargs.update({ 346 | 'report_activity': self.report_activity 347 | }) 348 | 349 | client, remote_path = get_transport_and_path(origin_uri, **client_kwargs) 350 | return client, remote_path 351 | 352 | def push_to(self, origin_uri, branch_name=None, progress=None): 353 | selector = self._wants_branch(branch_name=branch_name) 354 | client, remote_path = self.get_client(origin_uri) 355 | return client.send_pack( 356 | remote_path, 357 | selector, 358 | self.repo.object_store.generate_pack_contents, 359 | progress=progress 360 | ) 361 | 362 | # Like: git push 363 | def push(self, origin_uri=None, branch_name=None, progress=None): 364 | return self.push_to(origin_uri, branch_name, progress) 365 | 366 | # Not recommended at ALL ... !!! 367 | def dirty_pull_from(self, origin_uri, branch_name=None): 368 | # Remove all previously existing data 369 | rmtree(self.path) 370 | mkdir_safe(self.path) 371 | self.repo = DulwichRepo.init(self.path) 372 | 373 | # Fetch brand new copy from remote 374 | return self.pull_from(origin_uri, branch_name) 375 | 376 | def pull_from(self, origin_uri, branch_name=None): 377 | return self.fetch(origin_uri) 378 | 379 | # Like: git pull 380 | def pull(self, origin_uri=None, branch_name=None): 381 | return self.pull_from(origin_uri, branch_name) 382 | 383 | def fetch_remote(self, origin_uri=None): 384 | # Get client 385 | client, remote_path = self.get_client(origin_uri=origin_uri) 386 | 387 | # Fetch data from remote repository 388 | remote_refs = client.fetch(remote_path, self.repo) 389 | 390 | return remote_refs 391 | 392 | 393 | def _setup_fetched_refs(self, refs, origin, bare): 394 | remote_tags = utils.git.subrefs(refs, 'refs/tags') 395 | remote_heads = utils.git.subrefs(refs, 'refs/heads') 396 | 397 | # Filter refs 398 | clean_remote_tags = utils.git.clean_refs(remote_tags) 399 | clean_remote_heads = utils.git.clean_refs(remote_heads) 400 | 401 | # Base of new refs 402 | heads_base = 'refs/remotes/' + origin 403 | if bare: 404 | heads_base = 'refs/heads' 405 | 406 | # Import branches 407 | self.import_refs( 408 | heads_base, 409 | clean_remote_heads 410 | ) 411 | 412 | # Import tags 413 | self.import_refs( 414 | 'refs/tags', 415 | clean_remote_tags 416 | ) 417 | 418 | # Update HEAD 419 | for k, v in list(utils.git.clean_refs(refs).items()): 420 | self[k] = v 421 | 422 | 423 | def fetch(self, origin_uri=None, bare=None, origin=None): 424 | bare = bare or False 425 | origin = origin or self.DEFAULT_REMOTE 426 | 427 | # Remote refs 428 | remote_refs = self.fetch_remote(origin_uri) 429 | 430 | # Update head 431 | # Hit repo because head doesn't yet exist so 432 | # print("REFS = %s" % remote_refs) 433 | 434 | # If no refs (empty repository() 435 | if not remote_refs: 436 | return 437 | 438 | # Update refs (branches, tags, HEAD) 439 | self._setup_fetched_refs(remote_refs, origin, bare) 440 | 441 | # Checkout working directories 442 | if not bare and self.has_commits: 443 | self.checkout_all() 444 | else: 445 | self.update_server_info() 446 | 447 | 448 | @classmethod 449 | def clone(cls, origin_uri, local_path, auth=None, mkdir=True, bare=False, *args, **kwargs): 450 | """Clone a remote repository""" 451 | mkdir_safe(local_path) 452 | 453 | # Initialize the local repository 454 | if bare: 455 | local_repo = cls.init_bare(local_path) 456 | else: 457 | local_repo = cls.init(local_path) 458 | 459 | repo = cls(local_repo, origin_uri=origin_uri, auth=auth, *args, **kwargs) 460 | 461 | repo.fetch(bare=bare) 462 | 463 | # Add origin 464 | repo.add_remote('origin', origin_uri) 465 | 466 | return repo 467 | 468 | @classmethod 469 | def clone_bare(cls, *args, **kwargs): 470 | """Same as .clone except clones to a bare repository by default 471 | """ 472 | kwargs.setdefault('bare', True) 473 | return cls.clone(*args, **kwargs) 474 | 475 | def _commit(self, committer=None, author=None, message=None, files=None, tree=None, *args, **kwargs): 476 | 477 | if not tree: 478 | # If no tree then stage files 479 | modified_files = files or self.modified_files 480 | logging.info("STAGING : %s" % modified_files) 481 | self.repo.stage(modified_files) 482 | 483 | # Messages 484 | message = message or self.DEFAULT_MESSAGE 485 | author_msg = self._format_userinfo(author) 486 | committer_msg = self._format_userinfo(committer) 487 | 488 | return self.repo.do_commit( 489 | message=message, 490 | author=author_msg, 491 | committer=committer_msg, 492 | encoding='UTF-8', 493 | tree=tree, 494 | *args, **kwargs 495 | ) 496 | 497 | def _tree_from_structure(self, structure): 498 | # TODO : Support directories 499 | tree = Tree() 500 | 501 | for file_info in structure: 502 | 503 | # str only 504 | try: 505 | data = file_info['data'].encode('ascii') 506 | name = file_info['name'].encode('ascii') 507 | mode = file_info['mode'] 508 | except: 509 | # Skip file on encoding errors 510 | continue 511 | 512 | blob = Blob() 513 | 514 | blob.data = data 515 | 516 | # Store file's contents 517 | self.repo.object_store.add_object(blob) 518 | 519 | # Add blob entry 520 | tree.add( 521 | name, 522 | mode, 523 | blob.id 524 | ) 525 | 526 | # Store tree 527 | self.repo.object_store.add_object(tree) 528 | 529 | return tree.id 530 | 531 | # Like: git commmit -a 532 | def commit(self, name=None, email=None, message=None, files=None, *args, **kwargs): 533 | user_info = { 534 | 'name': name, 535 | 'email': email, 536 | } 537 | return self._commit( 538 | committer=user_info, 539 | author=user_info, 540 | message=message, 541 | files=files, 542 | *args, 543 | **kwargs 544 | ) 545 | 546 | def commit_structure(self, name=None, email=None, message=None, structure=None, *args, **kwargs): 547 | """Main use is to do commits directly to bare repositories 548 | For example doing a first Initial Commit so the repo can be cloned and worked on right away 549 | """ 550 | if not structure: 551 | return 552 | tree = self._tree_from_structure(structure) 553 | 554 | user_info = { 555 | 'name': name, 556 | 'email': email, 557 | } 558 | 559 | return self._commit( 560 | committer=user_info, 561 | author=user_info, 562 | message=message, 563 | tree=tree, 564 | *args, 565 | **kwargs 566 | ) 567 | 568 | # Push all local commits 569 | # and pull all remote commits 570 | def sync(self, origin_uri=None): 571 | self.push(origin_uri) 572 | return self.pull(origin_uri) 573 | 574 | def lookup_entry(self, relpath, trackable_files=set()): 575 | if not relpath in trackable_files: 576 | raise KeyError 577 | 578 | abspath = self.abspath(relpath) 579 | 580 | with open(abspath, 'rb') as git_file: 581 | data = git_file.read() 582 | s = sha1() 583 | s.update("blob %u\0" % len(data)) 584 | s.update(data) 585 | return (s.hexdigest(), os.stat(abspath).st_mode) 586 | 587 | @property 588 | @funky.transform(set) 589 | def tracked_files(self): 590 | return list(self.index) 591 | 592 | @property 593 | @funky.transform(set) 594 | def raw_files(self): 595 | return utils.paths.subpaths(self.path) 596 | 597 | @property 598 | @funky.transform(set) 599 | def ignored_files(self): 600 | return utils.paths.subpaths(self.path, filters=self.filters) 601 | 602 | @property 603 | @funky.transform(set) 604 | def trackable_files(self): 605 | return self.raw_files - self.ignored_files 606 | 607 | @property 608 | @funky.transform(set) 609 | def untracked_files(self): 610 | return self.trackable_files - self.tracked_files 611 | 612 | """ 613 | @property 614 | @funky.transform(set) 615 | def modified_staged_files(self): 616 | "Checks if the file has changed since last commit" 617 | timestamp = self.last_commit.commit_time 618 | index = self.index 619 | return [ 620 | f 621 | for f in self.tracked_files 622 | if index[f][1][0] > timestamp 623 | ] 624 | """ 625 | 626 | # Return a list of tuples 627 | # representing the changed elements in the git tree 628 | def _changed_entries(self, ref=None): 629 | ref = ref or self.DEFAULT_COMMIT 630 | if not self.has_commits: 631 | return [] 632 | obj_sto = self.repo.object_store 633 | tree_id = self[ref].tree 634 | names = self.trackable_files 635 | 636 | lookup_func = partial(self.lookup_entry, trackable_files=names) 637 | 638 | # Format = [((old_name, new_name), (old_mode, new_mode), (old_sha, new_sha)), ...] 639 | tree_diff = changes_from_tree(names, lookup_func, obj_sto, tree_id, want_unchanged=False) 640 | return list(tree_diff) 641 | 642 | @funky.transform(set) 643 | def _changed_entries_by_pattern(self, pattern): 644 | changed_entries = self._changed_entries() 645 | filtered_paths = None 646 | #if the pattern is PATTERN_MODIFIED, should check the sha 647 | if self.PATTERN_MODIFIED == pattern: 648 | filtered_paths = [ 649 | funky.first_true(names) 650 | for names, modes, sha in changed_entries 651 | if tuple(map(bool, names)) == pattern and funky.first_true(names) and sha[0] == sha[1] 652 | ] 653 | else : 654 | filtered_paths = [ 655 | funky.first_true(names) 656 | for names, modes, sha in changed_entries 657 | if tuple(map(bool, names)) == pattern and funky.first_true(names) 658 | ] 659 | return filtered_paths 660 | 661 | @property 662 | @funky.transform(set) 663 | def removed_files(self): 664 | return self._changed_entries_by_pattern(self.PATTERN_REMOVED) - self.ignored_files 665 | 666 | @property 667 | @funky.transform(set) 668 | def added_files(self): 669 | return self._changed_entries_by_pattern(self.PATTERN_ADDED) - self.ignored_files 670 | 671 | @property 672 | @funky.transform(set) 673 | def modified_files(self): 674 | modified_files = self._changed_entries_by_pattern(self.PATTERN_MODIFIED) - self.ignored_files 675 | return modified_files 676 | 677 | @property 678 | @funky.transform(set) 679 | def modified_unstaged_files(self): 680 | timestamp = self.last_commit.commit_time 681 | return [ 682 | f 683 | for f in self.tracked_files 684 | if os.stat(self.abspath(f)).st_mtime > timestamp 685 | ] 686 | 687 | @property 688 | def pending_files(self): 689 | """ 690 | Returns a list of all files that could be possibly staged 691 | """ 692 | # Union of both 693 | return self.modified_files | self.added_files | self.removed_files 694 | 695 | @property 696 | def pending_files_by_state(self): 697 | files = { 698 | 'modified': self.modified_files, 699 | 'added': self.added_files, 700 | 'removed': self.removed_files 701 | } 702 | 703 | # "Flip" the dictionary 704 | return { 705 | path: state 706 | for state, paths in list(files.items()) 707 | for path in paths 708 | } 709 | 710 | """ 711 | @property 712 | @funky.transform(set) 713 | def modified_files(self): 714 | return self.modified_staged_files | self.modified_unstaged_files 715 | """ 716 | 717 | # Like: git add 718 | @funky.arglist_method 719 | def stage(self, files): 720 | return self.repo.stage(files) 721 | 722 | def add(self, *args, **kwargs): 723 | return self.stage(*args, **kwargs) 724 | 725 | # Like: git rm 726 | @funky.arglist_method 727 | def rm(self, files, force=False): 728 | index = self.index 729 | index_files = [f for f in files if f in index] 730 | for f in index_files: 731 | del self.index[f] 732 | return index.write() 733 | 734 | def mv_fs(self, file_pair): 735 | old_name, new_name = file_pair 736 | os.rename(old_name, new_name) 737 | 738 | # Like: git mv 739 | @funky.arglist_method 740 | def mv(self, files_pair): 741 | index = self.index 742 | files_in_index = [f for f in files_pair if f[0] in index] 743 | list(map(self.mv_fs, files_in_index)) 744 | old_files = list(map(funky.first, files_in_index)) 745 | new_files = list(map(funky.last, files_in_index)) 746 | self.add(new_files) 747 | self.rm(old_files) 748 | self.add(old_files) 749 | return 750 | 751 | @working_only 752 | def _checkout_tree(self, tree): 753 | return build_index_from_tree( 754 | self.repo.path, 755 | self.repo.index_path(), 756 | self.repo.object_store, 757 | tree 758 | ) 759 | 760 | def checkout_all(self, commit_sha=None): 761 | commit_sha = commit_sha or self.head 762 | commit_tree = self._commit_tree(commit_sha) 763 | # Rebuild index from the current tree 764 | return self._checkout_tree(commit_tree) 765 | 766 | def checkout(self, ref): 767 | """Checkout a given ref or SHA 768 | """ 769 | self.repo.refs.set_symbolic_ref('HEAD', ref) 770 | commit_tree = self._commit_tree(ref) 771 | # Rebuild index from the current tree 772 | return self._checkout_tree(commit_tree) 773 | 774 | @funky.arglist_method 775 | def reset(self, files, commit='HEAD'): 776 | pass 777 | 778 | def rm_all(self): 779 | # if we go at the index via the property, it is reconstructed 780 | # each time and therefore clear() doesn't have the desired effect, 781 | # therefore, we cache it in a variable and use that. 782 | i = self.index 783 | i.clear() 784 | return i.write() 785 | 786 | def _to_commit(self, commit_obj): 787 | """Allows methods to accept both SHA's or dulwich Commit objects as arguments 788 | """ 789 | if isinstance(commit_obj, str): 790 | return self.repo[commit_obj] 791 | return commit_obj 792 | 793 | def _commit_sha(self, commit_obj): 794 | """Extracts a Dulwich commits SHA 795 | """ 796 | if utils.git.is_sha(commit_obj): 797 | return commit_obj 798 | elif isinstance(commit_obj, str): 799 | # Can't use self[commit_obj] to avoid infinite recursion 800 | commit_obj = self.repo[self.dwim_reference(commit_obj)] 801 | return commit_obj.id 802 | 803 | def dwim_reference(self, ref): 804 | """Dwim resolves a short reference to a full reference 805 | """ 806 | 807 | # Formats of refs we want to try in order 808 | formats = [ 809 | "%s", 810 | "refs/%s", 811 | "refs/tags/%s", 812 | "refs/heads/%s", 813 | "refs/remotes/%s", 814 | "refs/remotes/%s/HEAD", 815 | ] 816 | 817 | for f in formats: 818 | try: 819 | fullref = f % ref 820 | if not fullref in self.repo: 821 | continue 822 | return fullref 823 | except: 824 | continue 825 | 826 | raise Exception("Could not resolve ref") 827 | 828 | def blob_data(self, sha): 829 | """Return a blobs content for a given SHA 830 | """ 831 | return self[sha].data 832 | 833 | # Get the nth parent back for a given commit 834 | def get_parent_commit(self, commit, n=None): 835 | """ Recursively gets the nth parent for a given commit 836 | Warning: Remember that parents aren't the previous commits 837 | """ 838 | if n is None: 839 | n = 1 840 | commit = self._to_commit(commit) 841 | parents = commit.parents 842 | 843 | if n <= 0 or not parents: 844 | # Return a SHA 845 | return self._commit_sha(commit) 846 | 847 | parent_sha = parents[0] 848 | parent = self[parent_sha] 849 | 850 | # Recur 851 | return self.get_parent_commit(parent, n - 1) 852 | 853 | def get_previous_commit(self, commit_ref, n=None): 854 | commit_sha = self._parse_reference(commit_ref) 855 | n = n or 1 856 | commits = self.commits() 857 | return funky.next(commits, commit_sha, n=n, default=commit_sha) 858 | 859 | def _parse_reference(self, ref_string): 860 | # COMMIT_REF~x 861 | if '~' in ref_string: 862 | ref, count = ref_string.split('~') 863 | count = int(count) 864 | commit_sha = self._commit_sha(ref) 865 | return self.get_previous_commit(commit_sha, count) 866 | return self._commit_sha(ref_string) 867 | 868 | def _commit_tree(self, commit_sha): 869 | """Return the tree object for a given commit 870 | """ 871 | return self[commit_sha].tree 872 | 873 | def diff(self, commit_sha, compare_to=None, diff_type=None, filter_binary=True): 874 | diff_type = diff_type or self.DEFAULT_DIFF_TYPE 875 | diff_func = self.DIFF_FUNCTIONS[diff_type] 876 | 877 | if not compare_to: 878 | compare_to = self.get_previous_commit(commit_sha) 879 | 880 | return self._diff_between(compare_to, commit_sha, diff_function=diff_func) 881 | 882 | def diff_working(self, ref=None, filter_binary=True): 883 | """Diff between the current working directory and the HEAD 884 | """ 885 | return utils.git.diff_changes_paths( 886 | self.repo.object_store, 887 | self.path, 888 | self._changed_entries(ref=ref), 889 | filter_binary=filter_binary 890 | ) 891 | 892 | def get_commit_files(self, commit_sha, parent_path=None, is_tree=None, paths=None): 893 | """Returns a dict of the following Format : 894 | { 895 | "directory/filename.txt": { 896 | 'name': 'filename.txt', 897 | 'path': "directory/filename.txt", 898 | "sha": "xxxxxxxxxxxxxxxxxxxx", 899 | "data": "blablabla", 900 | "mode": 0xxxxx", 901 | }, 902 | ... 903 | } 904 | """ 905 | # Default values 906 | context = {} 907 | is_tree = is_tree or False 908 | parent_path = parent_path or '' 909 | 910 | if is_tree: 911 | tree = self[commit_sha] 912 | else: 913 | tree = self[self._commit_tree(commit_sha)] 914 | 915 | for entry in list(tree.items()): 916 | # Check if entry is a directory 917 | if entry.mode == self.MODE_DIRECTORY: 918 | context.update( 919 | self.get_commit_files(entry.sha, parent_path=os.path.join(parent_path, entry.path), is_tree=True, paths=paths) 920 | ) 921 | continue 922 | 923 | subpath = os.path.join(parent_path, entry.path) 924 | 925 | # Only add the files we want 926 | if not(paths is None or subpath in paths): 927 | continue 928 | 929 | # Add file entry 930 | context[subpath] = { 931 | 'name': entry.path, 932 | 'path': subpath, 933 | 'mode': entry.mode, 934 | 'sha': entry.sha, 935 | 'data': self.blob_data(entry.sha), 936 | } 937 | return context 938 | 939 | def file_versions(self, path): 940 | """Returns all commits where given file was modified 941 | """ 942 | versions = [] 943 | commits_info = self.commit_info() 944 | seen_shas = set() 945 | 946 | for commit in commits_info: 947 | try: 948 | files = self.get_commit_files(commit['sha'], paths=[path]) 949 | file_path, file_data = list(files.items())[0] 950 | except IndexError: 951 | continue 952 | 953 | file_sha = file_data['sha'] 954 | 955 | if file_sha in seen_shas: 956 | continue 957 | else: 958 | seen_shas.add(file_sha) 959 | 960 | # Add file info 961 | commit['file'] = file_data 962 | versions.append(file_data) 963 | return versions 964 | 965 | def _diff_between(self, old_commit_sha, new_commit_sha, diff_function=None, filter_binary=True): 966 | """Internal method for getting a diff between two commits 967 | Please use .diff method unless you have very specific needs 968 | """ 969 | 970 | # If commit is first commit (new_commit_sha == old_commit_sha) 971 | # then compare to an empty tree 972 | if new_commit_sha == old_commit_sha: 973 | old_tree = Tree() 974 | else: 975 | old_tree = self._commit_tree(old_commit_sha) 976 | 977 | new_tree = self._commit_tree(new_commit_sha) 978 | 979 | return diff_function(self.repo.object_store, old_tree, new_tree, filter_binary=filter_binary) 980 | 981 | def changes(self, *args, **kwargs): 982 | """ List of changes between two SHAs 983 | Returns a list of lists of tuples : 984 | [ 985 | [ 986 | (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) 987 | ], 988 | ... 989 | ] 990 | """ 991 | kwargs['diff_type'] = 'changes' 992 | return self.diff(*args, **kwargs) 993 | 994 | def changes_count(self, *args, **kwargs): 995 | return len(self.changes(*args, **kwargs)) 996 | 997 | def _refs_by_pattern(self, pattern): 998 | refs = self.refs 999 | 1000 | def item_filter(key_value): 1001 | """Filter only concered refs""" 1002 | key, value = key_value 1003 | return key.startswith(pattern) 1004 | 1005 | def item_map(key_value): 1006 | """Rewrite keys""" 1007 | key, value = key_value 1008 | new_key = key[len(pattern):] 1009 | return (new_key, value) 1010 | 1011 | return dict( 1012 | list(map(item_map, 1013 | list(filter( 1014 | item_filter, 1015 | list(refs.items()) 1016 | )) 1017 | )) 1018 | ) 1019 | 1020 | @property 1021 | def refs(self): 1022 | return self.repo.get_refs() 1023 | 1024 | def set_refs(refs_dict): 1025 | for k, v in list(refs_dict.items()): 1026 | self.repo[k] = v 1027 | 1028 | def import_refs(self, base, other): 1029 | return self.repo.refs.import_refs(base, other) 1030 | 1031 | @property 1032 | def branches(self): 1033 | return self._refs_by_pattern(self.REFS_BRANCHES) 1034 | 1035 | @property 1036 | def active_branch(self): 1037 | """Returns the name of the active branch, or None, if HEAD is detached 1038 | """ 1039 | x = self.repo.refs.read_ref('HEAD') 1040 | if not x.startswith(SYMREF): 1041 | return None 1042 | else: 1043 | symref = x[len(SYMREF):] 1044 | if not symref.startswith(self.REFS_BRANCHES): 1045 | return None 1046 | else: 1047 | return symref[len(self.REFS_BRANCHES):] 1048 | 1049 | @property 1050 | def active_sha(self): 1051 | """Deprecated equivalent to head property 1052 | """ 1053 | return self.head 1054 | 1055 | @property 1056 | def remote_branches(self): 1057 | return self._refs_by_pattern(self.REFS_REMOTES) 1058 | 1059 | @property 1060 | def tags(self): 1061 | return self._refs_by_pattern(self.REFS_TAGS) 1062 | 1063 | @property 1064 | def remotes(self): 1065 | """ Dict of remotes 1066 | { 1067 | 'origin': 'http://friendco.de/some_user/repo.git', 1068 | ... 1069 | } 1070 | """ 1071 | config = self.repo.get_config() 1072 | return { 1073 | keys[1]: values['url'] 1074 | for keys, values in list(config.items()) 1075 | if keys[0] == 'remote' 1076 | } 1077 | 1078 | def add_remote(self, remote_name, remote_url): 1079 | # Get repo's config 1080 | config = self.repo.get_config() 1081 | 1082 | # Add new entries for remote 1083 | config.set(('remote', remote_name), 'url', remote_url) 1084 | config.set(('remote', remote_name), 'fetch', "+refs/heads/*:refs/remotes/%s/*" % remote_name) 1085 | 1086 | # Write to disk 1087 | config.write_to_path() 1088 | 1089 | return remote_name 1090 | 1091 | def add_ref(self, new_ref, old_ref): 1092 | self.repo.refs[new_ref] = old_ref 1093 | self.update_server_info() 1094 | 1095 | def remove_ref(self, ref_name): 1096 | # Returns False if ref doesn't exist 1097 | if not ref_name in self.repo.refs: 1098 | return False 1099 | del self.repo.refs[ref_name] 1100 | self.update_server_info() 1101 | return True 1102 | 1103 | def create_branch(self, base_branch, new_branch, tracking=None): 1104 | """Try creating a new branch which tracks the given remote 1105 | if such a branch does not exist then branch off a local branch 1106 | """ 1107 | 1108 | # The remote to track 1109 | tracking = self.DEFAULT_REMOTE 1110 | 1111 | # Already exists 1112 | if new_branch in self.branches: 1113 | raise Exception("branch %s already exists" % new_branch) 1114 | 1115 | # Get information about remote_branch 1116 | remote_branch = os.path.sep.join([tracking, base_branch]) 1117 | 1118 | # Fork Local 1119 | if base_branch in self.branches: 1120 | base_ref = self._format_ref_branch(base_branch) 1121 | # Fork remote 1122 | elif remote_branch in self.remote_branches: 1123 | base_ref = self._format_ref_remote(remote_branch) 1124 | # TODO : track 1125 | else: 1126 | raise Exception("Can not find the branch named '%s' to fork either locally or in '%s'" % (base_branch, tracking)) 1127 | 1128 | # Reference of new branch 1129 | new_ref = self._format_ref_branch(new_branch) 1130 | 1131 | # Copy reference to create branch 1132 | self.add_ref(new_ref, base_ref) 1133 | 1134 | return new_ref 1135 | 1136 | def create_orphan_branch(self, new_branch, empty_index=None): 1137 | """ Create a new branch with no commits in it. 1138 | Technically, just points HEAD to a non-existent branch. The actual branch will 1139 | only be created if something is committed. This is equivalent to: 1140 | 1141 | git checkout --orphan , 1142 | 1143 | Unless empty_index is set to True, in which case the index will be emptied along 1144 | with the file-tree (which is always emptied). Against a clean working tree, 1145 | this is equivalent to: 1146 | 1147 | git checkout --orphan 1148 | git reset --merge 1149 | """ 1150 | if new_branch in self.branches: 1151 | raise Exception("branch %s already exists" % new_branch) 1152 | 1153 | new_ref = self._format_ref_branch(new_branch) 1154 | self.repo.refs.set_symbolic_ref('HEAD', new_ref) 1155 | 1156 | if self.is_working: 1157 | if empty_index: 1158 | self.rm_all() 1159 | self.clean_working() 1160 | 1161 | return new_ref 1162 | 1163 | def remove_branch(self, branch_name): 1164 | ref = self._format_ref_branch(branch_name) 1165 | return self.remove_ref(ref) 1166 | 1167 | def switch_branch(self, branch_name, tracking=None, create=None): 1168 | """Changes the current branch 1169 | """ 1170 | if create is None: 1171 | create = True 1172 | 1173 | # Check if branch exists 1174 | if not branch_name in self.branches: 1175 | self.create_branch(branch_name, branch_name, tracking=tracking) 1176 | 1177 | # Get branch reference 1178 | branch_ref = self._format_ref_branch(branch_name) 1179 | 1180 | # Change main branch 1181 | self.repo.refs.set_symbolic_ref('HEAD', branch_ref) 1182 | 1183 | if self.is_working: 1184 | # Remove all files 1185 | self.clean_working() 1186 | 1187 | # Add files for the current branch 1188 | self.checkout_all() 1189 | 1190 | def create_tag(self, tag_name, target): 1191 | ref = self._format_ref_tag(tag_name) 1192 | return self.add_ref(ref, self._parse_reference(target)) 1193 | 1194 | def remove_tag(self, tag_name): 1195 | ref = self._format_ref_tag(tag_name) 1196 | return self.remove_ref(ref) 1197 | 1198 | def clean(self, force=None, directories=None): 1199 | untracked_files = self.untracked_files 1200 | list(map(os.remove, untracked_files)) 1201 | return untracked_files 1202 | 1203 | def clean_working(self): 1204 | """Purges all the working (removes everything except .git) 1205 | used by checkout_all to get clean branch switching 1206 | """ 1207 | return self.clean() 1208 | 1209 | def _get_fs_structure(self, tree_sha, depth=None, parent_sha=None): 1210 | tree = self[tree_sha] 1211 | structure = {} 1212 | if depth is None: 1213 | depth = self.MAX_TREE_DEPTH 1214 | elif depth == 0: 1215 | return structure 1216 | for entry in list(tree.items()): 1217 | # tree 1218 | if entry.mode == self.MODE_DIRECTORY: 1219 | # Recur 1220 | structure[entry.path] = self._get_fs_structure(entry.sha, depth=depth - 1, parent_sha=tree_sha) 1221 | # commit 1222 | else: 1223 | structure[entry.path] = entry.sha 1224 | structure['.'] = tree_sha 1225 | structure['..'] = parent_sha or tree_sha 1226 | return structure 1227 | 1228 | def _get_fs_structure_by_path(self, tree_sha, path): 1229 | parts = path.split(os.path.sep) 1230 | depth = len(parts) + 1 1231 | structure = self._get_fs_structure(tree_sha, depth=depth) 1232 | 1233 | return funky.subkey(structure, parts) 1234 | 1235 | def commit_ls(self, ref, subpath=None): 1236 | """List a "directory" for a given commit 1237 | using the tree of that commit 1238 | """ 1239 | tree_sha = self._commit_tree(ref) 1240 | 1241 | # Root path 1242 | if subpath in self.ROOT_PATHS or not subpath: 1243 | return self._get_fs_structure(tree_sha, depth=1) 1244 | # Any other path 1245 | return self._get_fs_structure_by_path(tree_sha, subpath) 1246 | 1247 | def commit_file(self, ref, path): 1248 | """Return info on a given file for a given commit 1249 | """ 1250 | name, info = list(self.get_commit_files(ref, paths=[path]).items())[0] 1251 | return info 1252 | 1253 | def commit_tree(self, ref, *args, **kwargs): 1254 | tree_sha = self._commit_tree(ref) 1255 | return self._get_fs_structure(tree_sha, *args, **kwargs) 1256 | 1257 | def update_server_info(self): 1258 | if not self.is_bare: 1259 | return 1260 | update_server_info(self.repo) 1261 | 1262 | def _is_fast_forward(self): 1263 | pass 1264 | 1265 | def _merge_fast_forward(self): 1266 | pass 1267 | 1268 | def __hash__(self): 1269 | """This is required otherwise the memoize function will just mess it up 1270 | """ 1271 | return hash(self.path) 1272 | 1273 | def __getitem__(self, key): 1274 | try: 1275 | sha = self._parse_reference(key) 1276 | except: 1277 | raise KeyError(key) 1278 | return self.repo[sha] 1279 | 1280 | def __setitem__(self, key, value): 1281 | try: 1282 | key = self.dwim_reference(key) 1283 | except: 1284 | pass 1285 | self.repo[key] = value 1286 | 1287 | def __contains__(self, key): 1288 | try: 1289 | key = self.dwim_reference(key) 1290 | except: 1291 | pass 1292 | return key in self.repo 1293 | 1294 | def __delitem__(self, key): 1295 | try: 1296 | key = self.dwim_reference(key) 1297 | except: 1298 | raise KeyError(key) 1299 | self.remove_ref(key) 1300 | 1301 | 1302 | # Alias to clone_bare 1303 | fork = clone_bare 1304 | log = commit_info 1305 | diff_count = changes_count 1306 | contributors = recent_contributors 1307 | -------------------------------------------------------------------------------- /gittle/server.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | # Python imports 8 | import os 9 | 10 | # Dulwich imports 11 | from dulwich.server import FileSystemBackend, TCPGitServer, UploadPackHandler, ReceivePackHandler 12 | 13 | # Dict entries 14 | WRITE = (('git-upload-pack', UploadPackHandler),) 15 | READ = (('git-receive-pack', ReceivePackHandler),) 16 | 17 | READ_HANDLERS = dict(READ) 18 | 19 | WRITE_HANDLERS = dict(WRITE) 20 | 21 | READ_WRITE_HANDLERS = dict(READ + WRITE) 22 | 23 | PERM_MAPPING = { 24 | 'r': READ_HANDLERS, 25 | 'w': WRITE_HANDLERS, 26 | 'rw': READ_WRITE_HANDLERS, 27 | 'wr': READ_WRITE_HANDLERS, 28 | } 29 | 30 | 31 | class SubFileSystemBackend(FileSystemBackend): 32 | """A simple FileSystemBackend restricted to a given path 33 | """ 34 | def __init__(self, root_path): 35 | self.root_path = root_path 36 | 37 | def rewrite_path(self, path): 38 | return os.path.join(self.root_path, path) 39 | 40 | def open_repository(self, path): 41 | stripped_path = path.strip('/') 42 | full_path = self.rewrite_path(stripped_path) 43 | 44 | print(('opening %s' % path)) 45 | print(('full path = %s' % full_path)) 46 | 47 | return super(SubFileSystemBackend, self).open_repository(full_path) 48 | 49 | 50 | class GitServer(TCPGitServer): 51 | """Server using the git protocol over TCP 52 | """ 53 | def __init__(self, root_path=None, listen_addr=None, perm=None, *args, **kwargs): 54 | # Default values 55 | self.perm = perm or 'r' 56 | self.root_path = root_path or '/' 57 | self.listen_addr = listen_addr or 'localhost' 58 | 59 | # Backend 60 | backend = SubFileSystemBackend(self.root_path) 61 | 62 | # Handlers by permissions 63 | handlers = PERM_MAPPING.get(self.perm, READ_HANDLERS) 64 | 65 | # This is ugly and due to the fact that TCPGitServer is and old style class 66 | TCPGitServer.__init__(self, backend, self.listen_addr, handlers=handlers, *args, **kwargs) 67 | -------------------------------------------------------------------------------- /gittle/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import paths, urls, git 2 | -------------------------------------------------------------------------------- /gittle/utils/git.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Aaron O'Mullan 2 | # Copyright 2014 Christopher Corley 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 6 | # License, Version 2.0. See the COPYING file for further details. 7 | 8 | # Python imports 9 | import os 10 | 11 | try: 12 | from io import StringIO 13 | except ImportError: 14 | from io import StringIO 15 | 16 | from functools import partial 17 | 18 | # Dulwich imports 19 | from dulwich import patch 20 | from dulwich.objects import Blob 21 | from dulwich.patch import is_binary 22 | 23 | # Funky imports 24 | from funky import first, true_only, rest, negate, transform 25 | 26 | if os.sys.version_info.major > 2 or (os.sys.version_info.major == 2 and os.sys.version_info.minor < 7): 27 | str = str 28 | 29 | def is_readable(store): 30 | def fn(info): 31 | path, mode, sha = info 32 | return path is None or (type(store[sha]) is Blob and not is_binary(store[sha].data)) 33 | return fn 34 | 35 | def is_readable_change(store): 36 | def fn(change): 37 | return all( 38 | map(is_readable(store), change) 39 | ) 40 | return fn 41 | 42 | def is_unreadable_change(store): 43 | return negate(is_readable_change(store)) 44 | 45 | def dummy_diff(*args, **kwargs): 46 | return '' 47 | 48 | 49 | def commit_name_email(commit_author): 50 | try: 51 | name, email = commit_author.rsplit(' ', 1) 52 | # Extract the X from : "" 53 | email = email[1:-1] 54 | except: 55 | name = commit_author 56 | email = '' 57 | return name, email 58 | 59 | 60 | def contributor_from_raw(raw_author): 61 | name, email = commit_name_email(raw_author) 62 | return { 63 | 'name': name, 64 | 'email': email, 65 | 'raw': raw_author 66 | } 67 | 68 | 69 | def commit_info(commit): 70 | author = contributor_from_raw(commit.author) 71 | committer = contributor_from_raw(commit.committer) 72 | 73 | message_lines = commit.message.splitlines() 74 | summary = first(message_lines, '') 75 | description = '\n'.join( 76 | true_only( 77 | rest( 78 | message_lines 79 | ) 80 | ) 81 | ) 82 | 83 | return { 84 | 'author': author, 85 | 'committer': committer, 86 | 'sha': commit.sha().hexdigest(), 87 | 'time': commit.commit_time, 88 | 'timezone': commit.commit_timezone, 89 | 'message': commit.message, 90 | 'summary': summary, 91 | 'description': description, 92 | } 93 | 94 | 95 | def object_diff(*args, **kwargs): 96 | """A more convenient wrapper around Dulwich's patching 97 | """ 98 | fd = StringIO() 99 | patch.write_object_diff(fd, *args, **kwargs) 100 | return fd.getvalue() 101 | 102 | 103 | def blob_diff(object_store, *args, **kwargs): 104 | fd = StringIO() 105 | patch.write_blob_diff(fd, *args, **kwargs) 106 | return fd.getvalue() 107 | 108 | 109 | def changes_to_pairs(changes): 110 | return [ 111 | ((oldpath, oldmode, oldsha), (newpath, newmode, newsha),) 112 | for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes 113 | ] 114 | 115 | 116 | def _diff_pairs(object_store, pairs, diff_func, diff_type='text'): 117 | for old, new in pairs: 118 | yield { 'diff': diff_func(object_store, old, new), 119 | 'new': change_to_dict(new), 120 | 'old': change_to_dict(old), 121 | 'type': diff_type } 122 | 123 | 124 | def diff_changes(object_store, changes, diff_func=object_diff, filter_binary=True): 125 | """Return a dict of diffs for the changes 126 | """ 127 | pairs = changes_to_pairs(changes) 128 | readable_pairs = list(filter(is_readable_change(object_store), pairs)) 129 | unreadable_pairs = list(filter(is_unreadable_change(object_store), pairs)) 130 | 131 | for x in _diff_pairs(object_store, readable_pairs, diff_func): 132 | yield x 133 | for x in _diff_pairs(object_store, unreadable_pairs, dummy_diff, 'binary'): 134 | yield x 135 | 136 | 137 | def obj_blob(object_store, info): 138 | if not any(info): 139 | return info 140 | path, mode, sha = info 141 | return (path, mode, object_store[sha]) 142 | 143 | 144 | def path_blob(basepath, info): 145 | if not any(info): 146 | return info 147 | path, mode, sha = info 148 | return blob_from_path(basepath, path) 149 | 150 | 151 | def changes_to_blobs(object_store, basepath, pairs): 152 | return [ 153 | (obj_blob(object_store, old), path_blob(basepath, new),) 154 | for old, new in pairs 155 | ] 156 | 157 | 158 | def change_to_dict(info): 159 | path, mode, sha_or_blob = info 160 | 161 | if sha_or_blob and not is_sha(sha_or_blob): 162 | sha = sha_or_blob.id 163 | else: 164 | sha = sha_or_blob 165 | 166 | return { 167 | 'path': path, 168 | 'mode': mode, 169 | 'sha': sha, 170 | } 171 | 172 | 173 | def diff_changes_paths(object_store, basepath, changes, filter_binary=True): 174 | """Does a diff assuming that the old blobs are in git and others are unstaged blobs 175 | in the working directory 176 | """ 177 | pairs = changes_to_pairs(changes) 178 | readable_pairs = list(filter(is_readable_change(object_store), pairs)) 179 | unreadable_pairs = list(filter(is_unreadable_change(object_store), pairs)) 180 | 181 | blobs = changes_to_blobs(object_store, basepath, readable_pairs) 182 | 183 | for x in _diff_pairs(object_store, blobs, blob_diff): 184 | yield x 185 | for x in _diff_pairs(object_store, unreadable_pairs, dummy_diff, 'binary'): 186 | yield x 187 | 188 | 189 | def changes_tree_diff(object_store, old_tree, new_tree): 190 | return object_store.tree_changes(old_tree, new_tree) 191 | 192 | 193 | def dict_tree_diff(object_store, old_tree, new_tree, filter_binary=True): 194 | """Returns a dictionary where the keys are the filenames and their respective 195 | values are their diffs 196 | """ 197 | changes = changes_tree_diff(object_store, old_tree, new_tree) 198 | return diff_changes(object_store, changes, filter_binary=filter_binary) 199 | 200 | 201 | def classic_tree_diff(object_store, old_tree, new_tree, filter_binary=None): 202 | """Does a classic diff and returns the output in a buffer 203 | """ 204 | output = StringIO() 205 | 206 | # Write to output (our string) 207 | patch.write_tree_diff( 208 | output, 209 | object_store, 210 | old_tree, 211 | new_tree 212 | ) 213 | 214 | return output.getvalue() 215 | 216 | 217 | def prune_tree(tree, paths): 218 | """Return a tree with only entries matching the list of paths supplied 219 | """ 220 | raise NotImplemented() 221 | 222 | 223 | def is_sha(sha): 224 | return isinstance(sha, str) and len(sha) == 40 225 | 226 | 227 | def blob_from_path(basepath, path): 228 | """Returns a tuple of (sha_id, mode, blob) 229 | """ 230 | fullpath = os.path.join(basepath, path) 231 | with open(fullpath, 'rb') as working_file: 232 | blob = Blob() 233 | blob.data = working_file.read() 234 | return (path, os.stat(fullpath).st_mode, blob) 235 | 236 | 237 | def subkey(base, refkey): 238 | if not refkey.startswith(base): 239 | return None 240 | base_len = len(base) + 1 241 | return refkey[base_len:] 242 | 243 | 244 | def subrefs(refs_dict, base): 245 | """Return the contents of this container as a dictionary. 246 | """ 247 | base = base or '' 248 | keys = list(refs_dict.keys()) 249 | subkeys = list(map( 250 | partial(subkey, base), 251 | keys 252 | )) 253 | key_pairs = list(zip(keys, subkeys)) 254 | 255 | return { 256 | newkey: refs_dict[oldkey] 257 | for oldkey, newkey in key_pairs 258 | if newkey 259 | } 260 | 261 | 262 | def clean_refs(refs): 263 | return { 264 | ref: sha 265 | for ref, sha in list(refs.items()) 266 | if not ref.endswith('^{}') 267 | } 268 | -------------------------------------------------------------------------------- /gittle/utils/paths.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | # Python imports 8 | import os 9 | import re 10 | import fnmatch 11 | 12 | from funky import first, arglist 13 | 14 | 15 | # Path filters 16 | def path_filter_visible(path, abspath): 17 | return True 18 | 19 | 20 | def path_filter_file(path, abspath): 21 | return os.path.isfile(abspath) 22 | 23 | 24 | @arglist 25 | def path_filter_regex(regexes): 26 | compiled_regexes = list(map(re.compile, regexes)) 27 | 28 | def _filter(path, abspath): 29 | return any([ 30 | cre.match(abspath) 31 | for cre in compiled_regexes 32 | ]) 33 | return _filter 34 | 35 | 36 | @arglist 37 | def combine_filters(filters): 38 | def combined_filter(path, abspath): 39 | filter_results = [ 40 | _filter(path, abspath) 41 | for _filter in filters 42 | ] 43 | return all(filter_results) 44 | return combined_filter 45 | 46 | 47 | def abspaths_only(paths_couple): 48 | return [x[1] for x in paths_couple] 49 | 50 | 51 | def clean_relative_paths(paths): 52 | return [ 53 | p[2:] if p.startswith('./') else p 54 | for p in paths 55 | ] 56 | 57 | 58 | def dir_subpaths(root_path): 59 | """Get paths in a given directory""" 60 | paths = [] 61 | for dirname, dirnames, filenames in os.walk(root_path): 62 | 63 | # Add directory paths 64 | abs_dirnames = [ 65 | os.path.join(dirname, subdirname) 66 | for subdirname in dirnames 67 | ] 68 | rel_dirnames = [ 69 | os.path.relpath(abs_dirname, root_path) 70 | for abs_dirname in abs_dirnames 71 | ] 72 | paths.extend(list(zip( 73 | rel_dirnames, 74 | abs_dirnames, 75 | ))) 76 | 77 | abs_filenames = [ 78 | os.path.join(dirname, filename) 79 | for filename in filenames 80 | ] 81 | rel_filenames = [ 82 | os.path.relpath(abs_filename, root_path) 83 | for abs_filename in abs_filenames 84 | ] 85 | paths.extend(list(zip( 86 | rel_filenames, 87 | abs_filenames, 88 | ))) 89 | 90 | return paths 91 | 92 | 93 | def subpaths(root_path, filters=None): 94 | if filters is None: 95 | filters = [ 96 | path_filter_visible, 97 | path_filter_file, 98 | ] 99 | 100 | # One big filter which combines all other smaller filters 101 | big_filter = combine_filters(filters) 102 | filter_func = lambda x: big_filter(x[0], x[1]) 103 | 104 | paths = dir_subpaths(root_path) 105 | 106 | # Do filtering 107 | filtered_paths = list(filter(filter_func, paths)) 108 | relative_filtered_paths = list(map(first, filtered_paths)) 109 | return clean_relative_paths(relative_filtered_paths) 110 | 111 | 112 | @arglist 113 | def globers_to_regex(globers): 114 | return list(map(fnmatch.translate, globers)) 115 | -------------------------------------------------------------------------------- /gittle/utils/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Aaron O'Mullan 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 5 | # License, Version 2.0. See the COPYING file for further details. 6 | 7 | # Python imports 8 | try: 9 | from urllib.parse import urlparse 10 | except ImportError: 11 | from urllib.parse import urlparse 12 | 13 | # Local imports 14 | from funky import first_true 15 | 16 | 17 | def is_http_url(url, parsed): 18 | if parsed.scheme in ('http', 'https'): 19 | return parsed.scheme 20 | return None 21 | 22 | 23 | def is_git_url(url, parsed): 24 | if parsed.scheme == 'git': 25 | return parsed.scheme 26 | return None 27 | 28 | 29 | def is_ssh_url(url, parsed): 30 | if parsed.scheme == 'git+ssh': 31 | return parsed.scheme 32 | return None 33 | 34 | 35 | def get_protocol(url): 36 | schemers = [ 37 | is_git_url, 38 | is_ssh_url, 39 | is_http_url, 40 | ] 41 | 42 | parsed = urlparse(url) 43 | 44 | try: 45 | return first_true([ 46 | schemer(url, parsed) 47 | for schemer in schemers 48 | ]) 49 | except: 50 | pass 51 | return None 52 | 53 | 54 | def get_password(url): 55 | pass 56 | 57 | 58 | def get_user(url): 59 | pass 60 | 61 | 62 | def parse_url(url, defaults=None): 63 | """Parse a url corresponding to a git repository 64 | """ 65 | DEFAULTS = { 66 | 'protocol': 'git+ssh', 67 | } 68 | defaults = defaults or DEFAULTS 69 | 70 | protocol = get_protocol() or defaults.get('protocol') 71 | 72 | return { 73 | 'domain': domain, 74 | 'protocol': protocol, 75 | 'user': user, 76 | 'password': password, 77 | 'path': path, 78 | } 79 | 80 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright 2014 Aaron O'Mullan 4 | # Copyright 2013 Jace Browning 5 | # 6 | # This program is free software; you can redistribute it and/or 7 | # modify it only under the terms of the GNU GPLv2 and/or the Apache 8 | # License, Version 2.0. See the COPYING file for further details. 9 | 10 | """ 11 | Setup script for Gittle. 12 | """ 13 | 14 | import platform 15 | windows = platform.system() == 'Windows' 16 | try: 17 | from setuptools import setup 18 | except ImportError: 19 | has_setuptools = False 20 | from distutils.core import setup 21 | else: 22 | has_setuptools = True 23 | 24 | version_string = '0.5.0' 25 | 26 | setup_kwargs = { 27 | 'name': 'gittle', 28 | 'description': 'A high level pure python git implementation', 29 | 'keywords': 'git dulwich pure python gittle', 30 | 'version': version_string, 31 | 'url': 'https://github.com/FriendCode/gittle', 32 | 'license': 'MIT', 33 | 'author': "Aaron O'Mullan", 34 | 'author_email': 'aaron@friendco.de', 35 | 'long_description': """ 36 | Gittle is a wrapper around dulwich. It provides an easy and familiar interface to git. 37 | It's pure python (no dependency on the ``git`` binary) and has no other dependencies besides 38 | the python stdlib, dulwich and paramiko (optional). 39 | """, 40 | 'packages': ['gittle', 'gittle.utils'], 41 | 'install_requires': [ 42 | # PyPI 43 | 'paramiko>=1.10.0', 44 | 'pycrypto==2.6', 45 | 'dulwich>=0.9.7', 46 | 'funky>=0.0.2', 47 | ], 48 | } 49 | 50 | 51 | try: 52 | 53 | # Run setup with C extensions 54 | setup(**setup_kwargs) 55 | 56 | except SystemExit as exc: 57 | 58 | import logging 59 | logging.exception(exc) 60 | logging.info("retrying installation without VisualStudio...") 61 | 62 | # Remove C dependencies 63 | install_requires = [r for r in setup_kwargs['install_requires'] 64 | if r.split('=')[0] not in ('paramiko', 'pycrypto')] 65 | 66 | # Install dulwich as pure Python 67 | if windows and has_setuptools: 68 | from setuptools.command.easy_install import easy_install 69 | run_setup = easy_install.run_setup 70 | 71 | def _run_setup(self, setup_script, setup_base, args): 72 | """Alternate run_setup function to pass '--pure' to the 73 | Dulwich installer on Windows. 74 | """ 75 | if 'dulwich' in setup_script: 76 | args.insert(0, '--pure') 77 | run_setup(self, setup_script, setup_base, args) 78 | 79 | easy_install.run_setup = _run_setup 80 | 81 | # Run setup without C extensions 82 | setup_kwargs['install_requires'] = install_requires 83 | setup(**setup_kwargs) 84 | -------------------------------------------------------------------------------- /sitecustomize.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.setdefaultencoding('latin-1') 3 | --------------------------------------------------------------------------------