├── .gitignore ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── git_cc ├── __init__.py ├── cache.py ├── checkin.py ├── clearcase.py ├── common.py ├── gitcc.py ├── init.py ├── rebase.py ├── reset.py ├── status.py ├── sync.py ├── tag.py ├── update.py └── version.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── copy-data │ └── a.txt ├── output-as-set-data │ ├── a.txt │ └── b.txt ├── print_dir.py ├── sync-config │ ├── gitcc │ └── gitcc-empty ├── sync-data │ └── simple-tree │ │ ├── a.txt │ │ ├── lost+found │ │ └── c.txt │ │ └── subdir │ │ └── b.txt ├── test-cache.py ├── test-checkin.py ├── test_imports.py ├── test_print_version.py ├── test_sync.py ├── test_users_module_import.py └── user-config │ ├── gitcc │ ├── gitcc-abs │ ├── gitcc-empty │ └── users.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Emacs 2 | *~ 3 | *# 4 | 5 | # Python 6 | *.pyc 7 | .tox/ 8 | build/ 9 | dist/ 10 | git_cc.egg-info/ -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | Changelog for git-cc 2 | ==================== 3 | 4 | 1.0.1 (unreleased) 5 | ------------------ 6 | 7 | - gitcc --update only syncs files under ClearCase control. 8 | - Various fixes to work towards Python 3 compatibility. 9 | - Various improvements to the update command: 10 | - only copies files that have changed; 11 | - copied files have the same date and time as the original files. 12 | - Supports [tox] [tox] to package and run tests in virtualenvs. 13 | 14 | [tox]: http://tox.readthedocs.io/en/latest/ 15 | 16 | 1.0.0 (2016-07-03) 17 | ------------------ 18 | 19 | - Started versioning at 1.0.0 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | You may use, distribute and copy gitcc under the terms of GNU General 2 | Public License version 2, which is displayed below. 3 | 4 | ------------------------------------------------------------------------- 5 | 6 | GNU GENERAL PUBLIC LICENSE 7 | Version 2, June 1991 8 | 9 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 10 | 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 11 | Everyone is permitted to copy and distribute verbatim copies 12 | of this license document, but changing it is not allowed. 13 | 14 | Preamble 15 | 16 | The licenses for most software are designed to take away your 17 | freedom to share and change it. By contrast, the GNU General Public 18 | License is intended to guarantee your freedom to share and change free 19 | software--to make sure the software is free for all its users. This 20 | General Public License applies to most of the Free Software 21 | Foundation's software and to any other program whose authors commit to 22 | using it. (Some other Free Software Foundation software is covered by 23 | the GNU Library General Public License instead.) You can apply it to 24 | your programs, too. 25 | 26 | When we speak of free software, we are referring to freedom, not 27 | price. Our General Public Licenses are designed to make sure that you 28 | have the freedom to distribute copies of free software (and charge for 29 | this service if you wish), that you receive source code or can get it 30 | if you want it, that you can change the software or use pieces of it 31 | in new free programs; and that you know you can do these things. 32 | 33 | To protect your rights, we need to make restrictions that forbid 34 | anyone to deny you these rights or to ask you to surrender the rights. 35 | These restrictions translate to certain responsibilities for you if you 36 | distribute copies of the software, or if you modify it. 37 | 38 | For example, if you distribute copies of such a program, whether 39 | gratis or for a fee, you must give the recipients all the rights that 40 | you have. You must make sure that they, too, receive or can get the 41 | source code. And you must show them these terms so they know their 42 | rights. 43 | 44 | We protect your rights with two steps: (1) copyright the software, and 45 | (2) offer you this license which gives you legal permission to copy, 46 | distribute and/or modify the software. 47 | 48 | Also, for each author's protection and ours, we want to make certain 49 | that everyone understands that there is no warranty for this free 50 | software. If the software is modified by someone else and passed on, we 51 | want its recipients to know that what they have is not the original, so 52 | that any problems introduced by others will not reflect on the original 53 | authors' reputations. 54 | 55 | Finally, any free program is threatened constantly by software 56 | patents. We wish to avoid the danger that redistributors of a free 57 | program will individually obtain patent licenses, in effect making the 58 | program proprietary. To prevent this, we have made it clear that any 59 | patent must be licensed for everyone's free use or not licensed at all. 60 | 61 | The precise terms and conditions for copying, distribution and 62 | modification follow. 63 | 64 | GNU GENERAL PUBLIC LICENSE 65 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 66 | 67 | 0. This License applies to any program or other work which contains 68 | a notice placed by the copyright holder saying it may be distributed 69 | under the terms of this General Public License. The "Program", below, 70 | refers to any such program or work, and a "work based on the Program" 71 | means either the Program or any derivative work under copyright law: 72 | that is to say, a work containing the Program or a portion of it, 73 | either verbatim or with modifications and/or translated into another 74 | language. (Hereinafter, translation is included without limitation in 75 | the term "modification".) Each licensee is addressed as "you". 76 | 77 | Activities other than copying, distribution and modification are not 78 | covered by this License; they are outside its scope. The act of 79 | running the Program is not restricted, and the output from the Program 80 | is covered only if its contents constitute a work based on the 81 | Program (independent of having been made by running the Program). 82 | Whether that is true depends on what the Program does. 83 | 84 | 1. You may copy and distribute verbatim copies of the Program's 85 | source code as you receive it, in any medium, provided that you 86 | conspicuously and appropriately publish on each copy an appropriate 87 | copyright notice and disclaimer of warranty; keep intact all the 88 | notices that refer to this License and to the absence of any warranty; 89 | and give any other recipients of the Program a copy of this License 90 | along with the Program. 91 | 92 | You may charge a fee for the physical act of transferring a copy, and 93 | you may at your option offer warranty protection in exchange for a fee. 94 | 95 | 2. You may modify your copy or copies of the Program or any portion 96 | of it, thus forming a work based on the Program, and copy and 97 | distribute such modifications or work under the terms of Section 1 98 | above, provided that you also meet all of these conditions: 99 | 100 | a) You must cause the modified files to carry prominent notices 101 | stating that you changed the files and the date of any change. 102 | 103 | b) You must cause any work that you distribute or publish, that in 104 | whole or in part contains or is derived from the Program or any 105 | part thereof, to be licensed as a whole at no charge to all third 106 | parties under the terms of this License. 107 | 108 | c) If the modified program normally reads commands interactively 109 | when run, you must cause it, when started running for such 110 | interactive use in the most ordinary way, to print or display an 111 | announcement including an appropriate copyright notice and a 112 | notice that there is no warranty (or else, saying that you provide 113 | a warranty) and that users may redistribute the program under 114 | these conditions, and telling the user how to view a copy of this 115 | License. (Exception: if the Program itself is interactive but 116 | does not normally print such an announcement, your work based on 117 | the Program is not required to print an announcement.) 118 | 119 | These requirements apply to the modified work as a whole. If 120 | identifiable sections of that work are not derived from the Program, 121 | and can be reasonably considered independent and separate works in 122 | themselves, then this License, and its terms, do not apply to those 123 | sections when you distribute them as separate works. But when you 124 | distribute the same sections as part of a whole which is a work based 125 | on the Program, the distribution of the whole must be on the terms of 126 | this License, whose permissions for other licensees extend to the 127 | entire whole, and thus to each and every part regardless of who wrote it. 128 | 129 | Thus, it is not the intent of this section to claim rights or contest 130 | your rights to work written entirely by you; rather, the intent is to 131 | exercise the right to control the distribution of derivative or 132 | collective works based on the Program. 133 | 134 | In addition, mere aggregation of another work not based on the Program 135 | with the Program (or with a work based on the Program) on a volume of 136 | a storage or distribution medium does not bring the other work under 137 | the scope of this License. 138 | 139 | 3. You may copy and distribute the Program (or a work based on it, 140 | under Section 2) in object code or executable form under the terms of 141 | Sections 1 and 2 above provided that you also do one of the following: 142 | 143 | a) Accompany it with the complete corresponding machine-readable 144 | source code, which must be distributed under the terms of Sections 145 | 1 and 2 above on a medium customarily used for software interchange; or, 146 | 147 | b) Accompany it with a written offer, valid for at least three 148 | years, to give any third party, for a charge no more than your 149 | cost of physically performing source distribution, a complete 150 | machine-readable copy of the corresponding source code, to be 151 | distributed under the terms of Sections 1 and 2 above on a medium 152 | customarily used for software interchange; or, 153 | 154 | c) Accompany it with the information you received as to the offer 155 | to distribute corresponding source code. (This alternative is 156 | allowed only for noncommercial distribution and only if you 157 | received the program in object code or executable form with such 158 | an offer, in accord with Subsection b above.) 159 | 160 | The source code for a work means the preferred form of the work for 161 | making modifications to it. For an executable work, complete source 162 | code means all the source code for all modules it contains, plus any 163 | associated interface definition files, plus the scripts used to 164 | control compilation and installation of the executable. However, as a 165 | special exception, the source code distributed need not include 166 | anything that is normally distributed (in either source or binary 167 | form) with the major components (compiler, kernel, and so on) of the 168 | operating system on which the executable runs, unless that component 169 | itself accompanies the executable. 170 | 171 | If distribution of executable or object code is made by offering 172 | access to copy from a designated place, then offering equivalent 173 | access to copy the source code from the same place counts as 174 | distribution of the source code, even though third parties are not 175 | compelled to copy the source along with the object code. 176 | 177 | 4. You may not copy, modify, sublicense, or distribute the Program 178 | except as expressly provided under this License. Any attempt 179 | otherwise to copy, modify, sublicense or distribute the Program is 180 | void, and will automatically terminate your rights under this License. 181 | However, parties who have received copies, or rights, from you under 182 | this License will not have their licenses terminated so long as such 183 | parties remain in full compliance. 184 | 185 | 5. You are not required to accept this License, since you have not 186 | signed it. However, nothing else grants you permission to modify or 187 | distribute the Program or its derivative works. These actions are 188 | prohibited by law if you do not accept this License. Therefore, by 189 | modifying or distributing the Program (or any work based on the 190 | Program), you indicate your acceptance of this License to do so, and 191 | all its terms and conditions for copying, distributing or modifying 192 | the Program or works based on it. 193 | 194 | 6. Each time you redistribute the Program (or any work based on the 195 | Program), the recipient automatically receives a license from the 196 | original licensor to copy, distribute or modify the Program subject to 197 | these terms and conditions. You may not impose any further 198 | restrictions on the recipients' exercise of the rights granted herein. 199 | You are not responsible for enforcing compliance by third parties to 200 | this License. 201 | 202 | 7. If, as a consequence of a court judgment or allegation of patent 203 | infringement or for any other reason (not limited to patent issues), 204 | conditions are imposed on you (whether by court order, agreement or 205 | otherwise) that contradict the conditions of this License, they do not 206 | excuse you from the conditions of this License. If you cannot 207 | distribute so as to satisfy simultaneously your obligations under this 208 | License and any other pertinent obligations, then as a consequence you 209 | may not distribute the Program at all. For example, if a patent 210 | license would not permit royalty-free redistribution of the Program by 211 | all those who receive copies directly or indirectly through you, then 212 | the only way you could satisfy both it and this License would be to 213 | refrain entirely from distribution of the Program. 214 | 215 | If any portion of this section is held invalid or unenforceable under 216 | any particular circumstance, the balance of the section is intended to 217 | apply and the section as a whole is intended to apply in other 218 | circumstances. 219 | 220 | It is not the purpose of this section to induce you to infringe any 221 | patents or other property right claims or to contest validity of any 222 | such claims; this section has the sole purpose of protecting the 223 | integrity of the free software distribution system, which is 224 | implemented by public license practices. Many people have made 225 | generous contributions to the wide range of software distributed 226 | through that system in reliance on consistent application of that 227 | system; it is up to the author/donor to decide if he or she is willing 228 | to distribute software through any other system and a licensee cannot 229 | impose that choice. 230 | 231 | This section is intended to make thoroughly clear what is believed to 232 | be a consequence of the rest of this License. 233 | 234 | 8. If the distribution and/or use of the Program is restricted in 235 | certain countries either by patents or by copyrighted interfaces, the 236 | original copyright holder who places the Program under this License 237 | may add an explicit geographical distribution limitation excluding 238 | those countries, so that distribution is permitted only in or among 239 | countries not thus excluded. In such case, this License incorporates 240 | the limitation as if written in the body of this License. 241 | 242 | 9. The Free Software Foundation may publish revised and/or new versions 243 | of the General Public License from time to time. Such new versions will 244 | be similar in spirit to the present version, but may differ in detail to 245 | address new problems or concerns. 246 | 247 | Each version is given a distinguishing version number. If the Program 248 | specifies a version number of this License which applies to it and "any 249 | later version", you have the option of following the terms and conditions 250 | either of that version or of any later version published by the Free 251 | Software Foundation. If the Program does not specify a version number of 252 | this License, you may choose any version ever published by the Free Software 253 | Foundation. 254 | 255 | 10. If you wish to incorporate parts of the Program into other free 256 | programs whose distribution conditions are different, write to the author 257 | to ask for permission. For software which is copyrighted by the Free 258 | Software Foundation, write to the Free Software Foundation; we sometimes 259 | make exceptions for this. Our decision will be guided by the two goals 260 | of preserving the free status of all derivatives of our free software and 261 | of promoting the sharing and reuse of software generally. 262 | 263 | NO WARRANTY 264 | 265 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 266 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 267 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 268 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 269 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 270 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 271 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 272 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 273 | REPAIR OR CORRECTION. 274 | 275 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 276 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 277 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 278 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 279 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 280 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 281 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 282 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 283 | POSSIBILITY OF SUCH DAMAGES. 284 | 285 | END OF TERMS AND CONDITIONS 286 | 287 | How to Apply These Terms to Your New Programs 288 | 289 | If you develop a new program, and you want it to be of the greatest 290 | possible use to the public, the best way to achieve this is to make it 291 | free software which everyone can redistribute and change under these terms. 292 | 293 | To do so, attach the following notices to the program. It is safest 294 | to attach them to the start of each source file to most effectively 295 | convey the exclusion of warranty; and each file should have at least 296 | the "copyright" line and a pointer to where the full notice is found. 297 | 298 | 299 | Copyright (C) 300 | 301 | This program is free software; you can redistribute it and/or modify 302 | it under the terms of the GNU General Public License as published by 303 | the Free Software Foundation; either version 2 of the License, or 304 | (at your option) any later version. 305 | 306 | This program is distributed in the hope that it will be useful, 307 | but WITHOUT ANY WARRANTY; without even the implied warranty of 308 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 309 | GNU General Public License for more details. 310 | 311 | You should have received a copy of the GNU General Public License 312 | along with this program; if not, write to the Free Software 313 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 314 | 315 | 316 | Also add information on how to contact you by electronic and paper mail. 317 | 318 | If the program is interactive, make it output a short notice like this 319 | when it starts in an interactive mode: 320 | 321 | Gnomovision version 69, Copyright (C) year name of author 322 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 323 | This is free software, and you are welcome to redistribute it 324 | under certain conditions; type `show c' for details. 325 | 326 | The hypothetical commands `show w' and `show c' should show the appropriate 327 | parts of the General Public License. Of course, the commands you use may 328 | be called something other than `show w' and `show c'; they could even be 329 | mouse-clicks or menu items--whatever suits your program. 330 | 331 | You should also get your employer (if you work as a programmer) or your 332 | school, if any, to sign a "copyright disclaimer" for the program, if 333 | necessary. Here is a sample; alter the names: 334 | 335 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 336 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 337 | 338 | , 1 April 1989 339 | Ty Coon, President of Vice 340 | 341 | This General Public License does not permit incorporating your program into 342 | proprietary programs. If your program is a subroutine library, you may 343 | consider it more useful to permit linking proprietary applications with the 344 | library. If this is what you want to do, use the GNU Library General 345 | Public License instead of this License. 346 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.md setup.py setup.cfg 2 | recursive-include git_cc *.py 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # make this target from the root of the repo to run the unit tests 2 | .PHONY: tests 3 | tests: 4 | python -m unittest discover tests/ 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-cc 2 | 3 | Simple bridge between base ClearCase or UCM and Git. 4 | 5 | ## Warning 6 | 7 | I wrote this purely for fun and to see if I could stop use ClearCase at work 8 | once and for all. 9 | 10 | I will probably continue to hack away at it to suite my needs, but I would 11 | love to see it get some real-world polish. (Actually what I would love to see 12 | more is for ClearCase to die, but don't think that's going to happen any time 13 | soon). 14 | 15 | Suggestions on anything I've done are more than welcome. 16 | 17 | Also, I have made a change recently to support adding binary files which uses 18 | git-cat. Unfortunately git-cat doesn't handle end line conversions and so I 19 | have made gitcc init set core.autocrlf to false. This is only relevant for 20 | Windows users. Don't try changing this after your first commit either as it 21 | will only make matters worse. My apologies to anyone that is stung by this. 22 | 23 | ## Installation 24 | 25 | The easiest way to install git-cc, is to use the Python package installer pip 26 | and install it directly from its GitHub repo. Execute the following command on 27 | the command prompt to install the latest version: 28 | 29 | C:\> pip install git+git://github.com/charleso/git-cc.git#egg=git_cc 30 | 31 | If you installed Python from python.org, pip is included with Python 2 >= 2.7.9 32 | and Python 3 >= 3.4. If you do not have pip, [this section] [pip-installation] 33 | from the Python Packaging User Guide describes how to install it. 34 | 35 | In case pip or git cannot reach GitHub, for example when such access is not 36 | allowed in the place where you work, you can download the [zip file] [zip-file] 37 | with the latest version from the GitHub repo. Unzip it and use pip to execute 38 | the following command in the root of the directory tree: 39 | 40 | C:\master> pip install . 41 | 42 | Finally, if you cannot use pip, you can also use the old-skool approach to 43 | install Python packages: 44 | 45 | C:\master> python setup.py install 46 | 47 | ## Workflow 48 | 49 | Initialise: 50 | 51 | git init 52 | gitcc init d:/view/xyz 53 | gitcc rebase 54 | # Get coffee 55 | # Do some work 56 | git add . 57 | git commit -m "I don't actually drink coffee" 58 | gitcc rebase 59 | gitcc checkin 60 | 61 | Initialise (fast): 62 | 63 | Rebase can be quite slow initially, and if you just want to get a snapshot of 64 | ClearCase, without the history, then this is for you: 65 | 66 | gitcc init d:/view/xyz 67 | gitcc update "Initial commit" 68 | 69 | Other: 70 | 71 | These are two useful flags for rebase which is use quite frequently. 72 | 73 | gitcc rebase --stash 74 | 75 | Runs stash before the rebase, and pops it back on afterwards. 76 | 77 | gitcc rebase --dry-run 78 | 79 | Prints out the list of commits and modified files that are pending in ClearCase. 80 | 81 | To synchronise just a portion of your git history (instead of from the 82 | very first commit to HEAD), mark the start point with the command: 83 | 84 | gitcc tag 85 | 86 | To specify an existing ClearCase label while checking in, in order to let your 87 | dynamic view show the version of the element(s) just checked in if your 88 | confspec is configured accordingly, use the command: 89 | 90 | gitcc checkin --cclabel=YOUR_EXISTING_CC_LABEL 91 | 92 | Note that the CC label will be moved to the new version of the element, if it is already used. 93 | 94 | ## Configuration 95 | 96 | The file .git/gitcc contains configuration options for gitcc. For example, it 97 | allows you to limit which branches and folders you import from: 98 | 99 | [core] 100 | include = FolderA|FolderB 101 | exclude = FolderA/sub/folder|FolderB/other/file 102 | users_module_path = users.py 103 | ignore_private_files = False 104 | debug = False 105 | type = UCM 106 | [master] 107 | clearcase = D:\views\co4222_flex\rd_poc 108 | branches = main|ji_dev|ji_*_dev|iteration_*_dev 109 | [sup] 110 | clearcase = D:\views\co4222_sup\rd_poc 111 | branches = main|sup 112 | 113 | In this case there are two separate git branches, master and sup, which 114 | correspond to different folders/branches in ClearCase. 115 | 116 | You can add a mapping for each user in your ClearCase history. This is done via 117 | a separate Python module that you provide. An example users module looks 118 | like this: 119 | 120 | users = { 121 | 'charleso': "Charles O'Farrell",\ 122 | 'jki': 'Jan Kiszka ',\ 123 | } 124 | 125 | mailSuffix = 'example.com' 126 | 127 | You specify the path to the users module as the value of key 128 | 'users\_module\_path' in the gitcc config file. In the example above, the value 129 | specified is 'users.py'. If the path is relative, it is taken relative to the 130 | location of the config file. So in this example, gitcc will import users.py 131 | from directory .git. But you can also use an absolute users module path. 132 | 133 | If you do not specify the users module path in the config file, the ClearCase 134 | user information will be used. 135 | 136 | If you make a snapshot of a ClearCase VOB, you copy all the files that are 137 | visible in the view, including view-private files. This might not be what you 138 | want, for example if the VOB contains all kinds of build artifacts. To only 139 | copy the files that are actually under ClearCase control, set the key 140 | 'ignore\_private\_files' to True. 141 | 142 | ## Notes 143 | 144 | Can either work with static or dynamic views. I use dynamic at work because 145 | it's much faster not having to update. I've done an update in rebase anyway, 146 | just-in-case someone wants to use it that way. 147 | 148 | Can also work with UCM, which requires the 'type' config to be set to 'UCM'. 149 | This is still a work in progress as I only recently switched to this at work. 150 | Note the the history is still retrieved via lshistory and not specifically from 151 | any activity information. This is largely for convenience for me so I don't have 152 | to rewrite everything. Therefore things like 'recommended' baselines are ignored. 153 | I don't know if this will cause any major dramas or not. 154 | 155 | ## Troubleshooting 156 | 157 | 1. WindowsError: [Error 2] The system cannot find the file specified 158 | 159 | You're most likely running gitcc under Windows Cmd. At moment this isn't 160 | supported. Instead use Git Bash, which is a better console anyway. :-) 161 | 162 | If you have both msysgit and Cygwin installed then it may also be 163 | [this](https://github.com/charleso/git-cc/issues/10) problem. 164 | 165 | 2. cleartool: Error: Not an object in a vob: ".". 166 | 167 | The ClearCase directory you've specified in init isn't correct. Please note 168 | that the directory must be inside a VOB, which might be one of the folders 169 | inside the view you've specified. 170 | 171 | 3. fatal: ambiguous argument 'ClearCase': unknown revision or path not in the working tree. 172 | 173 | If this is your first rebase then please ignore this. This is expected. 174 | 175 | 4. pathspec 'master_cc' did not match any file(s) known to git 176 | 177 | See Issue [8](https://github.com/charleso/git-cc/issues/8). 178 | 179 | ## Behind the scenes 180 | 181 | A smart person would have looked at other git bridge implementations for 182 | inspiration, such as git-svn and the like. I, on the other hand, decided to go 183 | cowboy and re-invent the wheel. I have no idea how those other scripts do their 184 | business and so I hope this isn't a completely stupid way of going about it. 185 | 186 | I wanted to have it so that any point in history you could rebase on-top of the 187 | current working directory. I've done this by using the ClearCase commit time 188 | for git as well. In addition the last rebased commit is tagged and is used 189 | to limit the history query for any chances since. This tagged changeset is 190 | therefore also used to select which commits need to be checked into ClearCase. 191 | 192 | ## Problems 193 | 194 | It is worth nothing that when initially importing the history from ClearCase 195 | that files not currently in your view (ie deleted) cannot be reached without 196 | a config spec change. This is quite sad and means that the imported history is 197 | not a true one and so rolling back to older revisions will be somewhat limited 198 | as it is likely everything won't compile. Other ClearCase importers seem 199 | restricted by the same problem, but none-the-less it is most frustrating. Grr! 200 | 201 | ## For developers 202 | 203 | This section provides information for git-cc developers. 204 | 205 | ### Required packages 206 | 207 | To develop git-cc, several Python packages are required that are not part of 208 | the standard Python distribution and that you have to install separately. As 209 | these packages might conflict with your system Python environment, you are 210 | strongly advised to set up a [virtualenv] [virtualenv] for your work. 211 | 212 | The file 'requirements.txt', which is in the root of the repo, lists the Python 213 | packages that are needed for development. To install these packages, use the 214 | following command: 215 | 216 | git-cc $ pip install -r requirements.txt 217 | 218 | ### Testing 219 | 220 | git-cc comes with a small suite of unit tests, which you can find in 221 | subdirectory tests/. There are several ways to run the unit tests. For example, 222 | you can let Python search for the unit tests and run them in the current Python 223 | environment. To do so, execute the following command *from the root of the 224 | repo*: 225 | 226 | git-cc $ python -m unittest discover tests/ 227 | 228 | This will result in output such as this: 229 | 230 | ........ 231 | ---------------------------------------------------------------------- 232 | Ran 8 tests in 0.002s 233 | 234 | OK 235 | 236 | If you run the unit tests from the root of the repo, all unit tests will be 237 | able to import the git-cc package even when it is not installed. If you run the 238 | unit tests from another directory, you have to install git_cc first. 239 | 240 | Another way to run the unit test is to use the Python tool [tox] [tox]. tox 241 | does more than just run the unit tests: 242 | 243 | - it creates a source distribution of your Python package for each Python 244 | interpreter that you specify, 245 | - it creates a virtualenv for each Python interpreter you specify, and 246 | - for each virtualenv, installs the package using the source distribution and 247 | runs the unit tests. 248 | 249 | If you execute tox from the root of the repo, its output will look like this: 250 | 251 | git-cc $ tox 252 | GLOB sdist-make: /home/a-user/repos/github.com/git-cc/setup.py 253 | py27 inst-nodeps: /home/a-user/repos/github.com/git-cc/.tox/dist/git_cc-1.0.0.dev0.zip 254 | py27 installed: git-cc==1.0.0.dev0 255 | py27 runtests: PYTHONHASHSEED='2322284388' 256 | py27 runtests: commands[0] | python -m unittest discover tests/ 257 | ........ 258 | ---------------------------------------------------------------------- 259 | Ran 8 tests in 0.003s 260 | 261 | OK 262 | py34 inst-nodeps: /home/a-user/repos/github.com/git-cc/.tox/dist/git_cc-1.0.0.dev0.zip 263 | py34 installed: git-cc==1.0.0.dev0 264 | py34 runtests: PYTHONHASHSEED='2322284388' 265 | py34 runtests: commands[0] | python -m unittest discover tests/ 266 | ........ 267 | ---------------------------------------------------------------------- 268 | Ran 8 tests in 0.002s 269 | 270 | OK 271 | 272 | As the output shows, tox runs the tests in virtualenvs for Python 2.7 and 273 | Python 3.4. This has been specified in file tox.ini, which you can find in the 274 | root of the repo: 275 | 276 | [tox] 277 | envlist = py27,py34 278 | [testenv] 279 | commands=python -m unittest discover tests/ 280 | 281 | This only works if you have both interpreters installed. If you want to support 282 | other Python versions, you have to update the ini file accordingly. 283 | 284 | tox makes it easy to test your Python package in multiple Python 285 | environments. Running tox takes more time than just running the unit tests in 286 | your current environment. As such, developers run it less often, for example 287 | only before a new release or pull request. 288 | 289 | ### Changes and versioning 290 | 291 | The repo contains a CHANGES file that lists the changes for each git-cc release 292 | and for the version currently under development. This file has a specific 293 | format that best can be explained by an example. Assume the CHANGES file looks 294 | like this - note that the actual CHANGES file for git-cc looks different: 295 | 296 | Changelog 297 | ========= 298 | 299 | 1.2.0 (unreleased) 300 | ------------------ 301 | 302 | - Fixes issue Z 303 | 304 | 1.1.0 (2016-02-03) 305 | ------------------ 306 | 307 | - Adds support for feature Y 308 | - Updates documentation of feature X 309 | 310 | 1.0.0 (2016-01-03) 311 | ------------------ 312 | 313 | - Started versioning at 1.0.0 314 | 315 | The file mentions that versions 1.0.0 and 1.1.0 have been released and that the 316 | next version will be 1.2.0. 317 | 318 | The process of putting a new release out can be cumbersome and error prone: you 319 | have to update the CHANGES file and setup.py, create a tag, update the CHANGES 320 | file and setup.py again for the development version, etc. For git-cc, the 321 | process of creating a release is fully automated using tools provided by Python 322 | package [zest.releaser] [zest-releaser]. 323 | 324 | To show how zest.releaser works, the following is the (sanitized) output of the 325 | zest.releaser command 'fullrelease' with the example CHANGES file: 326 | 327 | # execute fullrelease on the command-line 328 | git-cc $ fullrelease 329 | 330 | # the command outputs the beginning of the CHANGES file 331 | Changelog entries for version 1.2.0: 332 | 333 | 1.2.0 (unreleased) 334 | ------------------ 335 | 336 | - Fixes issue Z 337 | 338 | 1.1.0 (2016-02-03) 339 | ------------------ 340 | # the command proposes to set the release version to 1.2.0 341 | Enter version [1.2.0]: 342 | # pressed RETURN to accept the proposed release version 343 | # the command automatically updates the CHANGES file and the version number used by setup.py 344 | 345 | OK to commit this (Y/n)? 346 | # pressed RETURN to commit the changes 347 | 348 | Tag needed to proceed, you can use the following command: 349 | git tag 1.2.0 -m "Tagging 1.2.0" 350 | Run this command (Y/n)? 351 | # pressed RETURN to tag the release 352 | 353 | Check out the tag (for tweaks or pypi/distutils server upload) (Y/n)? n 354 | # answered 'n' and pressed RETURN to not check out the tag 355 | 356 | Current version is 1.2.0 357 | # the command proposes to set the development version to 1.2.1dev0 358 | Enter new development version ('.dev0' will be appended) [1.2.1]: 359 | # pressed RETURN to accept the proposed development version 360 | # the command automatically updates the CHANGES file and the version number used by setup.py 361 | 362 | OK to commit this (Y/n)? 363 | # pressed RETURN to commit the changes 364 | 365 | OK to push commits to the server? (Y/n)? n 366 | # answered 'n' and pressed RETURN to not push the latest commit yet 367 | 368 | When the command is done, the beginning of the CHANGES file has changed to this: 369 | 370 | Changelog 371 | ========= 372 | 373 | 1.2.1 (unreleased) 374 | ------------------ 375 | 376 | - Nothing changed yet. 377 | 378 | 379 | 1.2.0 (2016-07-01) 380 | ------------------ 381 | 382 | - Fixes issue Z 383 | 384 | [pip-installation]: https://packaging.python.org/en/latest/installing/#requirements-for-installing-packages 385 | [tox]: http://tox.readthedocs.io/en/latest/ 386 | [virtualenv]: https://virtualenv.pypa.io/en/stable/ 387 | [zest-releaser]: http://zestreleaser.readthedocs.io/en/latest/index.html 388 | [zip-file]: https://github.com/charleso/git-cc/archive/master.zip 389 | -------------------------------------------------------------------------------- /git_cc/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.1.dev0' 2 | -------------------------------------------------------------------------------- /git_cc/cache.py: -------------------------------------------------------------------------------- 1 | from os.path import join, exists 2 | from .common import * 3 | 4 | FILE = '.gitcc' 5 | 6 | def getCache(): 7 | if cfg.getCore('cache', True) == 'False': 8 | return NoCache() 9 | return Cache(GIT_DIR) 10 | 11 | class Cache(object): 12 | def __init__(self, dir): 13 | self.map = {} 14 | self.file = FILE 15 | self.dir = dir 16 | self.empty = Version('/main/0') 17 | def start(self): 18 | f = join(self.dir, self.file) 19 | if exists(f): 20 | self.load(f) 21 | else: 22 | self.initial() 23 | def load(self, file): 24 | f = open(file, 'r') 25 | try: 26 | self.read(f.read()) 27 | finally: 28 | f.close() 29 | def initial(self): 30 | ls = ['ls', '-recurse', '-short'] 31 | ls.extend(cfg.getInclude()) 32 | self.read(cc_exec(ls)) 33 | def read(self, lines): 34 | for line in lines.splitlines(): 35 | if line.find('@@') < 0: 36 | continue 37 | self.update(CCFile2(line)) 38 | def update(self, path): 39 | isChild = self.map.get(path.file, self.empty).isChild(path.version) 40 | if isChild: 41 | self.map[path.file] = path.version 42 | return isChild or path.version.endswith(cfg.getBranches()[0]) 43 | def remove(self, file): 44 | if file in self.map: 45 | del self.map[file] 46 | def write(self): 47 | lines = [] 48 | keys = self.map.keys() 49 | keys = sorted(keys) 50 | for file in keys: 51 | lines.append(file + '@@' + self.map[file].full) 52 | f = open(join(self.dir, self.file), 'w') 53 | try: 54 | f.write('\n'.join(lines)) 55 | f.write('\n') 56 | finally: 57 | f.close() 58 | git_exec(['add', self.file]) 59 | def list(self): 60 | values = [] 61 | for file, version in self.map.items(): 62 | values.append(CCFile(file, version.full)) 63 | return values 64 | def contains(self, path): 65 | return self.map.get(path.file, self.empty).full == path.version.full 66 | 67 | class NoCache(object): 68 | def start(self): 69 | pass 70 | def write(self): 71 | pass 72 | def update(self, path): 73 | return True 74 | def remove(self, file): 75 | pass 76 | 77 | class CCFile(object): 78 | def __init__(self, file, version): 79 | if file.startswith('./') or file.startswith('.\\'): 80 | file = file[2:] 81 | self.file = file 82 | self.version = Version(version) 83 | 84 | class CCFile2(CCFile): 85 | def __init__(self, line): 86 | [file, version] = line.rsplit('@@', 1) 87 | super(CCFile2, self).__init__(file, version) 88 | 89 | class Version(object): 90 | def __init__(self, version): 91 | self.full = version.replace('\\', '/') 92 | self.version = '/'.join(self.full.split('/')[0:-1]) 93 | def isChild(self, version): 94 | return version.version.startswith(self.version) 95 | def endswith(self, version): 96 | return self.version.endswith('/' + version) 97 | -------------------------------------------------------------------------------- /git_cc/checkin.py: -------------------------------------------------------------------------------- 1 | """Checkin new git changesets to Clearcase""" 2 | 3 | from .common import * 4 | from .clearcase import cc 5 | from .status import Modify, Add, Delete, Rename, SymLink 6 | import filecmp 7 | from os import listdir 8 | from os.path import isdir 9 | from . import cache 10 | from . import reset 11 | 12 | IGNORE_CONFLICTS=False 13 | LOG_FORMAT = '%H%x01%B' 14 | CC_LABEL = '' 15 | 16 | ARGS = { 17 | 'force': 'ignore conflicts and check-in anyway', 18 | 'no_deliver': 'do not deliver in UCM mode', 19 | 'initial': 'checkin everything from the beginning', 20 | 'all': 'checkin all parents, not just the first', 21 | 'cclabel': 'optionally specify an existing Clearcase label type to apply to each element checked in', 22 | } 23 | 24 | def main(force=False, no_deliver=False, initial=False, all=False, cclabel=''): 25 | validateCC() 26 | global IGNORE_CONFLICTS 27 | global CC_LABEL 28 | if cclabel: 29 | CC_LABEL=cclabel 30 | if force: 31 | IGNORE_CONFLICTS=True 32 | cc_exec(['update', '.'], errors=False) 33 | log = ['log', '-z', '--reverse', '--pretty=format:'+ LOG_FORMAT ] 34 | if not all: 35 | log.append('--first-parent') 36 | if not initial: 37 | log.append(CI_TAG + '..') 38 | log = git_exec(log) 39 | if not log: 40 | return 41 | cc.rebase() 42 | for line in log.split('\x00'): 43 | id, comment = line.split('\x01') 44 | statuses = getStatuses(id, initial) 45 | checkout(statuses, comment.strip(), initial) 46 | tag(CI_TAG, id) 47 | if not no_deliver: 48 | cc.commit() 49 | if initial: 50 | git_exec(['commit', '--allow-empty', '-m', 'Empty commit']) 51 | reset.main('HEAD') 52 | 53 | def getStatuses(id, initial): 54 | cmd = ['diff','--name-status', '-M', '-z', '--ignore-submodules', '%s^..%s' % (id, id)] 55 | if initial: 56 | cmd = cmd[:-1] 57 | cmd[0] = 'show' 58 | cmd.extend(['--pretty=format:', id]) 59 | status = git_exec(cmd) 60 | status = status.strip() 61 | status = status.strip("\x00") 62 | types = {'M':Modify, 'R':Rename, 'D':Delete, 'A':Add, 'C':Add, 'S':SymLink} 63 | list = [] 64 | split = status.split('\x00') 65 | while len(split) > 1: 66 | char = split.pop(0)[0] # first char 67 | args = [split.pop(0)] 68 | # check if file is really a symlink 69 | cmd = ['ls-tree', '-z', id, '--', args[0]] 70 | if git_exec(cmd).split(' ')[0] == '120000': 71 | char = 'S' 72 | args.append(id) 73 | if char == 'R': 74 | args.append(split.pop(0)) 75 | elif char == 'C': 76 | args = [split.pop(0)] 77 | if args[0] == cache.FILE: 78 | continue 79 | type = types[char](args) 80 | type.id = id 81 | list.append(type) 82 | return list 83 | 84 | def checkout(stats, comment, initial): 85 | """Poor mans two-phase commit""" 86 | transaction = ITransaction(comment) if initial else Transaction(comment) 87 | for stat in stats: 88 | try: 89 | stat.stage(transaction) 90 | except: 91 | transaction.rollback() 92 | raise 93 | 94 | for stat in stats: 95 | stat.commit(transaction) 96 | transaction.commit(comment); 97 | 98 | class ITransaction(object): 99 | def __init__(self, comment): 100 | self.checkedout = [] 101 | self.cc_label = CC_LABEL 102 | cc.mkact(comment) 103 | def add(self, file): 104 | self.checkedout.append(file) 105 | def co(self, file): 106 | cc_exec(['co', '-reserved', '-nc', file]) 107 | if CC_LABEL: 108 | cc_exec(['mklabel', '-replace', '-nc', CC_LABEL, file]) 109 | self.add(file) 110 | def stageDir(self, file): 111 | file = file if file else '.' 112 | if file not in self.checkedout: 113 | self.co(file) 114 | def stage(self, file): 115 | self.co(file) 116 | def rollback(self): 117 | for file in self.checkedout: 118 | cc_exec(['unco', '-rm', file]) 119 | cc.rmactivity() 120 | def commit(self, comment): 121 | for file in self.checkedout: 122 | cc_exec(['ci', '-identical', '-c', comment, file]) 123 | 124 | class Transaction(ITransaction): 125 | def __init__(self, comment): 126 | super(Transaction, self).__init__(comment) 127 | self.base = git_exec(['merge-base', CI_TAG, 'HEAD']).strip() 128 | def stage(self, file): 129 | super(Transaction, self).stage(file) 130 | ccid = git_exec(['hash-object', join(CC_DIR, file)])[0:-1] 131 | gitid = getBlob(self.base, file) 132 | if ccid != gitid: 133 | if not IGNORE_CONFLICTS: 134 | raise Exception('File has been modified: %s. Try rebasing.' % file) 135 | else: 136 | print ('WARNING: Detected possible confilct with',file,'...ignoring...') 137 | -------------------------------------------------------------------------------- /git_cc/clearcase.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | 3 | class Clearcase: 4 | def rebase(self): 5 | pass 6 | def mkact(self, comment): 7 | pass 8 | def rmactivity(self): 9 | pass 10 | def commit(self): 11 | pass 12 | def getCommentFmt(self): 13 | return '%Nc' 14 | def getRealComment(self, comment): 15 | return comment 16 | 17 | class UCM: 18 | def __init__(self): 19 | self.activities = {} 20 | def rebase(self): 21 | out = cc_exec(['rebase', '-rec', '-f']) 22 | if not out.startswith('No rebase needed'): 23 | debug(out) 24 | debug(cc_exec(['rebase', '-complete'])) 25 | def mkact(self, comment): 26 | self.activity = self._getActivities().get(comment) 27 | if self.activity: 28 | cc_exec(['setact', self.activity]) 29 | return 30 | _comment = cc_exec(['mkact', '-f', '-headline', comment]) 31 | _comment = _comment.split('\n')[0] 32 | self.activity = _comment[_comment.find('"')+1:_comment.rfind('"')] 33 | self._getActivities()[comment] = self.activity 34 | def rmactivity(self): 35 | cc_exec(['setact', '-none']) 36 | cc_exec(['rmactivity', '-f', self.activity], errors=False) 37 | def commit(self): 38 | cc_exec(['setact', '-none']) 39 | debug(cc_exec(['deliver','-f'])) 40 | debug(cc_exec(['deliver', '-com', '-f'])) 41 | def getCommentFmt(self): 42 | return '%[activity]p' 43 | def getRealComment(self, activity): 44 | return cc_exec(['lsactivity', '-fmt', '%[headline]p', activity]) if activity else activity 45 | def _getActivities(self): 46 | if not self.activities: 47 | sep = '@@@' 48 | for line in cc_exec(['lsactivity', '-fmt', '%[headline]p|%n' + sep]).split(sep): 49 | if line: 50 | line = line.strip().split('|') 51 | self.activities[line[0]] = line[1] 52 | return self.activities 53 | 54 | cc = (UCM if cfg.getCore('type') == 'UCM' else Clearcase)(); 55 | -------------------------------------------------------------------------------- /git_cc/common.py: -------------------------------------------------------------------------------- 1 | from subprocess import Popen, PIPE 2 | import imp 3 | import os 4 | import sys 5 | from os.path import join, exists, abspath, dirname 6 | 7 | # In which package module SafeConfigParser is available and under what name 8 | # depends on the Python version 9 | if sys.version_info[0] == 2: 10 | from ConfigParser import SafeConfigParser 11 | elif sys.version_info[0] == 3 and sys.version_info[1] <= 2: 12 | from configparser import SafeConfigParser 13 | else: 14 | from configparser import ConfigParser as SafeConfigParser 15 | 16 | IS_CYGWIN = sys.platform == 'cygwin' 17 | 18 | if IS_CYGWIN: 19 | FS = '\\' 20 | else: 21 | FS = os.sep 22 | 23 | 24 | class FakeUsersModule(): 25 | 26 | def __init__(self): 27 | self.users = {} 28 | self.mailSuffix = "" 29 | 30 | 31 | def get_users_module(path): 32 | """Load the module at the given path and return it. 33 | 34 | The path should point to a module that defines at its top-level a users 35 | dictionary ".users" and a mail suffix ".mailSuffix". 36 | 37 | If no file exists at the given path, this function returns an object with 38 | an empty dictionary for ".users" and the empty string for ".mailSuffix". 39 | 40 | """ 41 | 42 | users_module = FakeUsersModule() 43 | if os.path.exists(path): 44 | users_module_path = os.path.join(path) 45 | users_module = imp.load_source("users", users_module_path) 46 | 47 | return users_module 48 | 49 | CFG_CC = 'clearcase' 50 | CC_DIR = None 51 | ENCODING = None 52 | if hasattr(sys.stdin, 'encoding'): 53 | ENCODING = sys.stdin.encoding 54 | if ENCODING is None: 55 | import locale 56 | locale_name, ENCODING = locale.getdefaultlocale() 57 | if ENCODING is None: 58 | ENCODING = "ISO8859-1" 59 | DEBUG = False 60 | 61 | def fail(string): 62 | print(string) 63 | sys.exit(2) 64 | 65 | def doStash(f, stash): 66 | if(stash): 67 | git_exec(['stash']) 68 | f() 69 | if(stash): 70 | git_exec(['stash', 'pop']) 71 | 72 | def debug(string): 73 | if DEBUG: 74 | print(string) 75 | 76 | def git_exec(cmd, **args): 77 | return popen('git', cmd, GIT_DIR, encoding='UTF-8', **args) 78 | 79 | def cc_exec(cmd, **args): 80 | return popen('cleartool', cmd, CC_DIR, **args) 81 | 82 | def popen(exe, cmd, cwd, env=None, decode=True, errors=True, encoding=None): 83 | cmd.insert(0, exe) 84 | if DEBUG: 85 | f = lambda a: a if not a.count(' ') else '"%s"' % a 86 | debug('> ' + ' '.join(map(f, cmd))) 87 | pipe = Popen(cmd, cwd=cwd, stdout=PIPE, stderr=PIPE, env=env) 88 | (stdout, stderr) = pipe.communicate() 89 | if encoding == None: 90 | encoding = ENCODING 91 | if errors and pipe.returncode > 0: 92 | raise Exception(decodeString(encoding, stderr + stdout)) 93 | return stdout if not decode else decodeString(encoding, stdout) 94 | 95 | def decodeString(encoding, encodestr): 96 | try: 97 | return encodestr.decode(encoding) 98 | except UnicodeDecodeError as e: 99 | print >> sys.stderr, encodestr, ":", e 100 | return ''.join([c if ord(c) < 128 else '' for c in encodestr]) 101 | 102 | def tag(tag, id="HEAD"): 103 | git_exec(['tag', '-f', tag, id]) 104 | 105 | def reset(tag=None): 106 | git_exec(['reset', '--hard', tag or CC_TAG]) 107 | 108 | def getBlob(sha, file): 109 | return git_exec(['ls-tree', '-z', sha, file]).split(' ')[2].split('\t')[0] 110 | 111 | def gitDir(): 112 | def findGitDir(dir): 113 | if not exists(dir) or dirname(dir) == dir: 114 | return '.' 115 | if exists(join(dir, '.git')): 116 | return dir 117 | return findGitDir(dirname(dir)) 118 | return findGitDir(abspath('.')) 119 | 120 | def getCurrentBranch(): 121 | for branch in git_exec(['branch']).split('\n'): 122 | if branch.startswith('*'): 123 | branch = branch[2:] 124 | if branch == '(no branch)': 125 | fail("Why aren't you on a branch?") 126 | return branch 127 | return "" 128 | 129 | class GitConfigParser(): 130 | CORE = 'core' 131 | def __init__(self, branch, config_file=None): 132 | self.section = branch 133 | self.file = config_file 134 | if not self.file: 135 | self.file = join(GIT_DIR, '.git', 'gitcc') 136 | self.parser = SafeConfigParser() 137 | self.parser.add_section(self.section) 138 | def set(self, name, value): 139 | self.parser.set(self.section, name, value) 140 | def read(self): 141 | self.parser.read(self.file) 142 | def write(self): 143 | self.parser.write(open(self.file, 'w')) 144 | def getCore(self, name, *args): 145 | return self._get(self.CORE, name, *args) 146 | def get(self, name, *args): 147 | return self._get(self.section, name, *args) 148 | def _get(self, section, name, default=None): 149 | if not self.parser.has_option(section, name): 150 | return default 151 | return self.parser.get(section, name) 152 | def getList(self, name, default=None): 153 | return self.get(name, default).split('|') 154 | def getInclude(self): 155 | return self.getCore('include', '.').split('|') 156 | def getExclude(self): 157 | return self.getCore('exclude', '.').split('|') 158 | def getBranches(self): 159 | return self.getList('branches', 'main') 160 | def getExtraBranches(self): 161 | return self.getList('_branches', 'main') 162 | 163 | def getUsersModulePath(self): 164 | """Return the absolute path of the users module. 165 | 166 | In the configuration file, the path can be specified by an absolute 167 | path but also by a relative path. If it is a relative path, the path is 168 | taken relative to the directory that contains the configuration file. 169 | 170 | If the configuration file does not specify the path to the users 171 | module, this method returns the empty string. 172 | 173 | """ 174 | abs_path = '' 175 | path = self.getCore('users_module_path') 176 | if path is not None: 177 | if os.path.isabs(path): 178 | abs_path = path 179 | else: 180 | config_dir = os.path.dirname(self.file) 181 | abs_path = os.path.join(config_dir, path) 182 | return abs_path 183 | 184 | def ignorePrivateFiles(self): 185 | """Return true if and only if private files should not be synced. 186 | 187 | If this option holds, only the files that are under ClearCase control 188 | will be synced. Otherwise all the files in the VOB are synced. 189 | 190 | """ 191 | return self.getCore('ignore_private_files', False) 192 | 193 | 194 | def write(file, blob): 195 | _write(file, blob) 196 | 197 | def _write(file, blob): 198 | f = open(file, 'wb') 199 | f.write(blob) 200 | f.close() 201 | 202 | def mkdirs(file): 203 | dir = dirname(file) 204 | if not exists(dir): 205 | os.makedirs(dir) 206 | 207 | def removeFile(file): 208 | if exists(file): 209 | os.remove(file) 210 | 211 | def validateCC(): 212 | if not CC_DIR: 213 | fail("No 'clearcase' variable found for branch '%s'" % CURRENT_BRANCH) 214 | 215 | def path(path, args='-m'): 216 | if IS_CYGWIN: 217 | return os.popen('cygpath %s "%s"' %(args, path)).readlines()[0].strip() 218 | else: 219 | return path 220 | 221 | GIT_DIR = path(gitDir()) 222 | if not exists(join(GIT_DIR, '.git')): 223 | fail("fatal: Not a git repository (or any of the parent directories): .git") 224 | CURRENT_BRANCH = getCurrentBranch() or 'master' 225 | cfg = GitConfigParser(CURRENT_BRANCH) 226 | cfg.read() 227 | CC_DIR = path(cfg.get(CFG_CC)) 228 | DEBUG = str(cfg.getCore('debug', True)) == str(True) 229 | CC_TAG = CURRENT_BRANCH + '_cc' 230 | CI_TAG = CURRENT_BRANCH + '_ci' 231 | users = get_users_module(cfg.getUsersModulePath()) 232 | -------------------------------------------------------------------------------- /git_cc/gitcc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import inspect 3 | import sys 4 | 5 | from optparse import OptionParser 6 | 7 | from . import checkin 8 | from . import init 9 | from . import rebase 10 | from . import reset 11 | from . import sync 12 | from . import tag 13 | from . import update 14 | from . import version 15 | 16 | commands = [ 17 | init, rebase, checkin, sync, reset, tag, update, version 18 | ] 19 | 20 | 21 | def main(): 22 | args = sys.argv[1:] 23 | for cmd in commands: 24 | if args and get_module_name(cmd) == args[0]: 25 | return invoke(cmd, args) 26 | usage() 27 | 28 | 29 | def invoke(cmd, args): 30 | _args, _, _, defaults = inspect.getargspec(cmd.main) 31 | defaults = defaults if defaults else [] 32 | diff = len(_args) - len(defaults) 33 | _args = _args[diff:] 34 | parser = OptionParser(description=cmd.__doc__) 35 | for (name, default) in zip(_args, defaults): 36 | option = { 37 | 'default': default, 38 | 'help': cmd.ARGS[name], 39 | 'dest': name, 40 | } 41 | if default is False: 42 | option['action'] = "store_true" 43 | elif default is None: 44 | option['action'] = "store" 45 | name = name.replace('_', '-') 46 | parser.add_option('--' + name, **option) 47 | (options, args) = parser.parse_args(args[1:]) 48 | if len(args) < diff: 49 | parser.error("incorrect number of arguments") 50 | for name in _args: 51 | args.append(getattr(options, name)) 52 | cmd.main(*args) 53 | 54 | 55 | def usage(): 56 | print('usage: gitcc COMMAND [ARGS]\n') 57 | width = 11 58 | for cmd in commands: 59 | print(' %s %s' % (get_module_name(cmd).ljust(width), 60 | cmd.__doc__.split('\n')[0])) 61 | sys.exit(2) 62 | 63 | 64 | def get_module_name(module): 65 | """Return the name of the given module, without the package name. 66 | 67 | For example, if the given module is checkin, the module name is 68 | "git_cc.checkin" and without the package name is "checkin". 69 | 70 | Note that the given module should already have been imported. 71 | 72 | """ 73 | _, _, module_name = module.__name__.rpartition('.') 74 | return module_name 75 | 76 | if __name__ == '__main__': 77 | main() 78 | -------------------------------------------------------------------------------- /git_cc/init.py: -------------------------------------------------------------------------------- 1 | """Initialise gitcc with a clearcase directory""" 2 | 3 | from .common import * 4 | from os import open 5 | from os.path import join, exists 6 | 7 | def main(ccdir): 8 | git_exec(['config', 'core.autocrlf', 'false']) 9 | cfg.set(CFG_CC, ccdir) 10 | cfg.write() 11 | -------------------------------------------------------------------------------- /git_cc/rebase.py: -------------------------------------------------------------------------------- 1 | """Rebase from Clearcase""" 2 | 3 | from os.path import join, dirname, exists, isdir 4 | import os, stat 5 | from .common import * 6 | from datetime import datetime, timedelta 7 | from fnmatch import fnmatch 8 | from .clearcase import cc 9 | from .cache import getCache, CCFile 10 | from re import search 11 | 12 | """ 13 | Things remaining: 14 | 1. Renames with no content change. Tricky. 15 | """ 16 | 17 | CC_LSH = ['lsh', '-fmt', '%o%m|%Nd|%u|%En|%Vn|'+cc.getCommentFmt()+'\\n', '-recurse'] 18 | DELIM = '|' 19 | 20 | ARGS = { 21 | 'stash': 'Wraps the rebase in a stash to avoid file changes being lost', 22 | 'dry_run': 'Prints a list of changesets to be imported', 23 | 'lshistory': 'Prints the raw output of lshistory to be cached for load', 24 | 'load': 'Loads the contents of a previously saved lshistory file', 25 | } 26 | 27 | cache = getCache() 28 | 29 | def main(stash=False, dry_run=False, lshistory=False, load=None): 30 | validateCC() 31 | if not (stash or dry_run or lshistory): 32 | checkPristine() 33 | 34 | cc_exec(["update"], errors=False) 35 | 36 | since = getSince() 37 | cache.start() 38 | if load: 39 | history = open(load, 'r').read().decode(ENCODING) 40 | else: 41 | cc.rebase() 42 | history = getHistory(since) 43 | write(join(GIT_DIR, '.git', 'lshistory.bak'), history.encode(ENCODING)) 44 | if lshistory: 45 | print(history) 46 | else: 47 | cs = parseHistory(history) 48 | cs = reversed(cs) 49 | cs = mergeHistory(cs) 50 | if dry_run: 51 | return printGroups(cs) 52 | if not len(cs): 53 | return 54 | doStash(lambda: doCommit(cs), stash) 55 | 56 | def checkPristine(): 57 | if(len(git_exec(['ls-files', '--modified']).splitlines()) > 0): 58 | fail('There are uncommitted files in your git directory') 59 | 60 | def doCommit(cs): 61 | branch = getCurrentBranch() 62 | if branch: 63 | git_exec(['checkout', CC_TAG]) 64 | try: 65 | commit(cs) 66 | finally: 67 | if branch: 68 | git_exec(['rebase', CI_TAG, CC_TAG]) 69 | git_exec(['rebase', CC_TAG, branch]) 70 | else: 71 | git_exec(['branch', '-f', CC_TAG]) 72 | tag(CI_TAG, CC_TAG) 73 | 74 | def getSince(): 75 | try: 76 | date = git_exec(['log', '-n', '1', '--pretty=format:%ai', '%s' % CC_TAG]) 77 | date = date[:19] 78 | date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S') 79 | date = date + timedelta(seconds=1) 80 | return datetime.strftime(date, '%d-%b-%Y.%H:%M:%S') 81 | except: 82 | return cfg.get('since') 83 | 84 | def getHistory(since): 85 | lsh = CC_LSH[:] 86 | if since: 87 | lsh.extend(['-since', since]) 88 | lsh.extend(cfg.getInclude()) 89 | return cc_exec(lsh) 90 | 91 | def filterBranches(version, all=False): 92 | version = version.split(FS) 93 | version.pop() 94 | version = version[-1] 95 | branches = cfg.getBranches(); 96 | if all: 97 | branches.extend(cfg.getExtraBranches()) 98 | for branch in branches: 99 | if fnmatch(version, branch): 100 | return True 101 | return False 102 | 103 | def parseHistory(lines): 104 | changesets = [] 105 | def add(split, comment): 106 | if not split: 107 | return 108 | cstype = split[0] 109 | if cstype in TYPES: 110 | cs = TYPES[cstype](split, comment) 111 | try: 112 | if filterBranches(cs.version): 113 | changesets.append(cs) 114 | except Exception as e: 115 | print('Bad line', split, comment) 116 | raise 117 | last = None 118 | comment = None 119 | for line in lines.splitlines(): 120 | split = line.split(DELIM) 121 | if len(split) < 6 and last: 122 | # Cope with comments with '|' character in them 123 | comment += "\n" + DELIM.join(split) 124 | else: 125 | add(last, comment) 126 | comment = DELIM.join(split[5:]) 127 | last = split 128 | add(last, comment) 129 | return changesets 130 | 131 | def mergeHistory(changesets): 132 | last = None 133 | groups = [] 134 | def same(a, b): 135 | return a.subject == b.subject and a.user == b.user 136 | for cs in changesets: 137 | if last and same(last, cs): 138 | last.append(cs) 139 | else: 140 | last = Group(cs) 141 | groups.append(last) 142 | for group in groups: 143 | group.fixComment() 144 | return groups 145 | 146 | def commit(list): 147 | for cs in list: 148 | cs.commit() 149 | 150 | def printGroups(groups): 151 | for cs in groups: 152 | print('%s "%s"' % (cs.user, cs.subject)) 153 | for file in cs.files: 154 | print(" %s" % file.file) 155 | 156 | class Group: 157 | def __init__(self, cs): 158 | self.user = cs.user 159 | self.comment = cs.comment 160 | self.subject = cs.subject 161 | self.files = [] 162 | self.append(cs) 163 | def append(self, cs): 164 | self.date = cs.date 165 | self.files.append(cs) 166 | def fixComment(self): 167 | self.comment = cc.getRealComment(self.comment) 168 | self.subject = self.comment.split('\n')[0] 169 | def commit(self): 170 | def getCommitDate(date): 171 | return date[:4] + '-' + date[4:6] + '-' + date[6:8] + ' ' + \ 172 | date[9:11] + ':' + date[11:13] + ':' + date[13:15] 173 | def getUserName(user): 174 | return str(user).split(' <')[0] 175 | def getUserEmail(user): 176 | email = search('<.*@.*>', str(user)) 177 | if email == None: 178 | return '<%s@%s>' % (user.lower().replace(' ','.').replace("'", ''), users.mailSuffix) 179 | else: 180 | return email.group(0) 181 | files = [] 182 | for file in self.files: 183 | files.append(file.file) 184 | for file in self.files: 185 | file.add(files) 186 | cache.write() 187 | env = os.environ 188 | user = users.users.get(self.user, self.user) 189 | env['GIT_AUTHOR_DATE'] = env['GIT_COMMITTER_DATE'] = str(getCommitDate(self.date)) 190 | env['GIT_AUTHOR_NAME'] = env['GIT_COMMITTER_NAME'] = getUserName(user) 191 | env['GIT_AUTHOR_EMAIL'] = env['GIT_COMMITTER_EMAIL'] = str(getUserEmail(user)) 192 | comment = self.comment if self.comment.strip() != "" else "" 193 | try: 194 | git_exec(['commit', '-m', comment.encode(ENCODING)], env=env) 195 | except Exception as e: 196 | if search('nothing( added)? to commit', e.args[0]) == None: 197 | raise 198 | 199 | def cc_file(file, version): 200 | return '%s@@%s' % (file, version) 201 | 202 | class Changeset(object): 203 | def __init__(self, split, comment): 204 | self.date = split[1] 205 | self.user = split[2] 206 | self.file = split[3] 207 | self.version = split[4] 208 | self.comment = comment 209 | self.subject = comment.split('\n')[0] 210 | def add(self, files): 211 | self._add(self.file, self.version) 212 | def _add(self, file, version): 213 | if not cache.update(CCFile(file, version)): 214 | return 215 | if [e for e in cfg.getExclude() if fnmatch(file, e)]: 216 | return 217 | toFile = path(join(GIT_DIR, file)) 218 | mkdirs(toFile) 219 | removeFile(toFile) 220 | try: 221 | cc_exec(['get','-to', toFile, cc_file(file, version)]) 222 | except: 223 | if len(file) < 200: 224 | raise 225 | debug("Ignoring %s as it may be related to https://github.com/charleso/git-cc/issues/9" % file) 226 | if not exists(toFile): 227 | git_exec(['checkout', 'HEAD', toFile]) 228 | else: 229 | os.chmod(toFile, os.stat(toFile).st_mode | stat.S_IWRITE) 230 | git_exec(['add', '-f', file], errors=False) 231 | 232 | class Uncataloged(Changeset): 233 | def add(self, files): 234 | dir = path(cc_file(self.file, self.version)) 235 | diff = cc_exec(['diff', '-diff_format', '-pred', dir], errors=False) 236 | def getFile(line): 237 | return join(self.file, line[2:max(line.find(' '), line.find(FS + ' '))]) 238 | for line in diff.split('\n'): 239 | sym = line.find(' -> ') 240 | if sym >= 0: 241 | continue 242 | if line.startswith('<'): 243 | git_exec(['rm', '-r', getFile(line)], errors=False) 244 | cache.remove(getFile(line)) 245 | elif line.startswith('>'): 246 | added = getFile(line) 247 | cc_added = join(CC_DIR, added) 248 | if not exists(cc_added) or isdir(cc_added) or added in files: 249 | continue 250 | history = cc_exec(['lshistory', '-fmt', '%o%m|%Nd|%Vn\\n', added], errors=False) 251 | if not history: 252 | continue 253 | history = filter(None, history.split('\n')) 254 | all_versions = self.parse_history(history) 255 | 256 | date = cc_exec(['describe', '-fmt', '%Nd', dir]) 257 | actual_versions = self.filter_versions(all_versions, lambda x: x[1] < date) 258 | 259 | versions = self.checkin_versions(actual_versions) 260 | if not versions: 261 | print("It appears that you may be missing a branch in the includes section of your gitcc config for file '%s'." % added) 262 | continue 263 | self._add(added, versions[0][2].strip()) 264 | 265 | def checkin_versions(self, versions): 266 | return self.filter_versions_by_type(versions, 'checkinversion') 267 | 268 | def filter_versions_by_type(self, versions, type): 269 | def f(s): 270 | return s[0] == type and filterBranches(s[2], True) 271 | return self.filter_versions(versions, f) 272 | 273 | def filter_versions(self, versions, handler): 274 | return list(filter(handler, versions)) 275 | 276 | def parse_history(self, history_arr): 277 | return list(map(lambda x: x.split('|'), history_arr)) 278 | 279 | 280 | TYPES = {\ 281 | 'checkinversion': Changeset,\ 282 | 'checkindirectory version': Uncataloged,\ 283 | } 284 | -------------------------------------------------------------------------------- /git_cc/reset.py: -------------------------------------------------------------------------------- 1 | """Reset hard to a specific changeset""" 2 | 3 | from .common import * 4 | 5 | def main(commit): 6 | git_exec(['branch', '-f', CC_TAG, commit]) 7 | tag(CI_TAG, commit) 8 | -------------------------------------------------------------------------------- /git_cc/status.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | from os.path import join, dirname 3 | 4 | class Status: 5 | def __init__(self, files): 6 | self.setFile(files[0]) 7 | def setFile(self, file): 8 | self.file = file 9 | def cat(self): 10 | blob = git_exec(['cat-file', 'blob', getBlob(self.id, self.file)], decode=False) 11 | write(join(CC_DIR, self.file), blob) 12 | def stageDirs(self, t): 13 | dir = dirname(self.file) 14 | dirs = [] 15 | while not exists(join(CC_DIR, dir)): 16 | dirs.append(dir) 17 | dir = dirname(dir) 18 | self.dirs = dirs 19 | t.stageDir(dir) 20 | def commitDirs(self, t): 21 | while len(self.dirs) > 0: 22 | dir = self.dirs.pop(); 23 | if not exists(join(CC_DIR, dir)): 24 | cc_exec(['mkelem', '-nc', '-eltype', 'directory', dir]) 25 | if t.cc_label: 26 | cc_exec(['mklabel', '-nc', t.cc_label, dir]) 27 | t.add(dir) 28 | 29 | class Modify(Status): 30 | def stage(self, t): 31 | t.stage(self.file) 32 | def commit(self, t): 33 | self.cat() 34 | 35 | class Add(Status): 36 | def stage(self, t): 37 | self.stageDirs(t) 38 | def commit(self, t): 39 | self.commitDirs(t) 40 | self.cat() 41 | cc_exec(['mkelem', '-nc', self.file]) 42 | if t.cc_label: 43 | cc_exec(['mklabel', '-nc', t.cc_label, self.file]) 44 | t.add(self.file) 45 | 46 | class Delete(Status): 47 | def stage(self, t): 48 | t.stageDir(dirname(self.file)) 49 | def commit(self, t): 50 | # TODO Empty dirs?!? 51 | cc_exec(['rm', self.file]) 52 | 53 | class Rename(Status): 54 | def __init__(self, files): 55 | self.old = files[0] 56 | self.new = files[1] 57 | self.setFile(self.new) 58 | def stage(self, t): 59 | t.stageDir(dirname(self.old)) 60 | t.stage(self.old) 61 | self.stageDirs(t) 62 | def commit(self, t): 63 | self.commitDirs(t) 64 | cc_exec(['mv', '-nc', self.old, self.new]) 65 | t.checkedout.remove(self.old) 66 | t.add(self.new) 67 | self.cat() 68 | 69 | class SymLink(Status): 70 | def __init__(self, files): 71 | self.setFile(files[0]) 72 | id = files[1] 73 | self.target = git_exec(['cat-file', 'blob', getBlob(id, self.file)], decode=False) 74 | if exists(join(CC_DIR, self.file)): 75 | self.rmfirst=True 76 | else: 77 | self.rmfirst=False 78 | def stage(self, t): 79 | self.stageDirs(t) 80 | def commit(self, t): 81 | if self.rmfirst: 82 | cc_exec(['rm', self.file]) 83 | cc_exec(['ln', '-s', self.target, self.file]) 84 | -------------------------------------------------------------------------------- /git_cc/sync.py: -------------------------------------------------------------------------------- 1 | """Copy files from Clearcase to Git manually""" 2 | 3 | import filecmp 4 | import os.path 5 | import shutil 6 | import stat 7 | import subprocess 8 | 9 | from fnmatch import fnmatch 10 | 11 | from .cache import Cache 12 | from .common import CC_DIR 13 | from .common import GIT_DIR 14 | from .common import cfg 15 | from .common import debug 16 | from .common import mkdirs 17 | from .common import validateCC 18 | 19 | ARGS = { 20 | 'cache': 'Use the cache for faster syncing', 21 | 'dry_run': 'Only print the paths of files to be synced' 22 | } 23 | 24 | 25 | class SyncFile(object): 26 | """Implements the copying of a file.""" 27 | 28 | def do_sync(self, file_name, src_dir, dst_dir): 29 | """Copies the given file from its source to its destination directory. 30 | 31 | If the file already exists in the destination directory, this function 32 | only overwrites the destination file if the contents are different. 33 | 34 | This function returns True if and only if the file is actually copied. 35 | 36 | The destination file gets read and write permissions. It also gets the 37 | same last access time and last modification time as the source file. 38 | 39 | """ 40 | src_file = os.path.join(src_dir, file_name) 41 | dst_file = os.path.join(dst_dir, file_name) 42 | copy_file = not os.path.exists(dst_file) or \ 43 | not filecmp.cmp(src_file, dst_file, shallow=False) 44 | if copy_file: 45 | debug('Copying to %s' % dst_file) 46 | self._sync(src_file, dst_file) 47 | return copy_file 48 | 49 | def _sync(self, src_file, dst_file): 50 | mkdirs(dst_file) 51 | shutil.copy2(src_file, dst_file) 52 | os.chmod(dst_file, stat.S_IREAD | stat.S_IWRITE) 53 | 54 | 55 | class IgnoreFile(SyncFile): 56 | 57 | def _sync(self, src_file, dst_file): 58 | pass 59 | 60 | 61 | class Sync(object): 62 | """Implements the copying of multiple directory trees.""" 63 | 64 | def __init__(self, src_root, src_dirs, dst_root, sync_file=SyncFile()): 65 | self.src_root = os.path.abspath(src_root) 66 | self.src_dirs = src_dirs 67 | self.dst_root = os.path.abspath(dst_root) 68 | 69 | self.sync_file = sync_file 70 | 71 | def do_sync(self): 72 | copied_file_count = 0 73 | for rel_dir, file_names in self.iter_src_files(): 74 | for file_name in file_names: 75 | file_path = os.path.join(rel_dir, file_name) 76 | if self.sync_file.do_sync(file_path, 77 | self.src_root, 78 | self.dst_root): 79 | copied_file_count += 1 80 | return copied_file_count 81 | 82 | def iter_src_files(self): 83 | for src_dir in self.src_dirs: 84 | root_dir = os.path.join(self.src_root, src_dir) 85 | root_dir_length = len(root_dir) 86 | for abs_dir, _, file_names in os.walk(root_dir): 87 | rel_dir = abs_dir[root_dir_length + 1:] 88 | yield rel_dir, file_names 89 | 90 | 91 | class ClearCaseSync(Sync): 92 | """Implements the copying of multiple directory trees under ClearCase.""" 93 | 94 | def iter_src_files(self): 95 | 96 | private_files = self.collect_private_files() 97 | 98 | def under_vc(rel_dir, file_name): 99 | path = os.path.join(self.src_root, rel_dir, file_name) 100 | return path not in private_files 101 | 102 | iter_src_files = super(ClearCaseSync, self).iter_src_files 103 | for rel_dir, files in iter_src_files(): 104 | if fnmatch(rel_dir, "lost+found"): 105 | continue 106 | yield rel_dir, filter(lambda f: under_vc(rel_dir, f), files) 107 | 108 | def collect_private_files(self): 109 | """Return the set of private files. 110 | 111 | Each private file is specified by its complete path. The ClearCase 112 | command to list the private files returns them like that. 113 | 114 | """ 115 | command = "cleartool ls -recurse -view_only {}".format(self.src_root) 116 | return output_as_set(command.split(' ')) 117 | 118 | 119 | def main(cache=False, dry_run=False): 120 | validateCC() 121 | if cache: 122 | return syncCache() 123 | 124 | src_root = CC_DIR 125 | src_dirs = cfg.getInclude() 126 | dst_root = GIT_DIR 127 | sync_file = SyncFile() if not dry_run else IgnoreFile() 128 | 129 | # determine whether we should sync all files or only the files that are 130 | # under ClearCase control 131 | syncClass = Sync 132 | if cfg.ignorePrivateFiles(): 133 | syncClass = ClearCaseSync 134 | 135 | return syncClass(src_root, src_dirs, dst_root, sync_file).do_sync() 136 | 137 | 138 | def output_as_set(command): 139 | """Execute the given command. 140 | 141 | Arguments: 142 | command -- list that specifies the command and its arguments 143 | 144 | An example of a command is ["ls", "-la"], which specifies the execution of 145 | the "ls -la". 146 | 147 | """ 148 | # The universal_newlines keyword argument in the next statement makes sure 149 | # the output stream is a text stream instead of a byte stream. In that way 150 | # the output results in a sequence of strings, which is easier to process 151 | # than a sequence of byte sequences. 152 | p = subprocess.Popen( 153 | command, stdout=subprocess.PIPE, universal_newlines=True) 154 | result = set(line.rstrip() for line in p.stdout) 155 | p.stdout.close() 156 | return result 157 | 158 | 159 | def syncCache(): 160 | cache1 = Cache(GIT_DIR) 161 | cache1.start() 162 | 163 | cache2 = Cache(GIT_DIR) 164 | cache2.initial() 165 | 166 | copied_file_count = 0 167 | for path in cache2.list(): 168 | if not cache1.contains(path): 169 | cache1.update(path) 170 | if not os.path.isdir(os.path.join(CC_DIR, path.file)): 171 | if copy(path.file): 172 | copied_file_count += 1 173 | cache1.write() 174 | return copied_file_count 175 | -------------------------------------------------------------------------------- /git_cc/tag.py: -------------------------------------------------------------------------------- 1 | """Tag a particular commit as gitcc start point""" 2 | 3 | from .common import * 4 | 5 | def main(commit): 6 | tag(CI_TAG, commit) 7 | -------------------------------------------------------------------------------- /git_cc/update.py: -------------------------------------------------------------------------------- 1 | """Update the git repository with Clearcase manually, ignoring history""" 2 | 3 | from __future__ import print_function 4 | 5 | from .common import * 6 | from . import reset 7 | from . import sync 8 | 9 | 10 | def main(message): 11 | cc_exec(['update', '.'], errors=False) 12 | if sync.main(): 13 | git_exec(['add', '.']) 14 | git_exec(['commit', '-m', message]) 15 | reset.main('HEAD') 16 | else: 17 | print("No files have changed, nothing to commit.") 18 | -------------------------------------------------------------------------------- /git_cc/version.py: -------------------------------------------------------------------------------- 1 | """Display the git-cc version""" 2 | 3 | from __future__ import print_function 4 | 5 | import git_cc 6 | 7 | 8 | def main(): 9 | """Print the git--c version number.""" 10 | print(git_cc.__version__) 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama==0.3.7 2 | funcsigs==1.0.2 3 | mock==2.0.0 4 | pbr==1.10.0 5 | pkg-resources==0.0.0 6 | pkginfo==1.3.2 7 | pluggy==0.3.1 8 | py==1.4.31 9 | requests==2.20.0 10 | requests-toolbelt==0.6.2 11 | six==1.10.0 12 | tox==2.3.1 13 | twine==1.6.5 14 | virtualenv==15.0.2 15 | zest.releaser==6.6.4 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [zest.releaser] 2 | python-file-with-version = git_cc/__init__.py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | 6 | from setuptools import setup, find_packages 7 | 8 | # The following approach to retrieve the version number is inspired by this 9 | # comment: 10 | # 11 | # https://github.com/zestsoftware/zest.releaser/issues/37#issuecomment-14496733 12 | # 13 | # With this approach, development installs always show the right version number 14 | # and do not require a reinstall (as the definition of the version number in 15 | # this setup file would). 16 | 17 | version = 'no version defined' 18 | current_dir = os.path.dirname(__file__) 19 | with open(os.path.join(current_dir, "git_cc", "__init__.py")) as f: 20 | rx = re.compile("__version__ = '(.*)'") 21 | for line in f: 22 | m = rx.match(line) 23 | if m: 24 | version = m.group(1) 25 | 26 | with open('README.md') as f: 27 | readme = f.read() 28 | 29 | with open('LICENSE') as f: 30 | license = f.read() 31 | 32 | setup( 33 | name='git_cc', 34 | version=version, 35 | description='Provides a bridge between git and ClearCase', 36 | long_description=readme, 37 | author="Charles O'Farrel and others", 38 | url='https://github.com/charleso/git-cc', 39 | license=license, 40 | packages=find_packages(exclude=('tests', 'docs')), 41 | entry_points={ 42 | 'console_scripts': [ 43 | 'gitcc=git_cc.gitcc:main', 44 | ], 45 | }, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys, os, shutil 2 | sys.path.append("..") 3 | import common 4 | import unittest 5 | 6 | common.CC_DIR = "/tmp/cc_temp" 7 | 8 | class TestCaseEx(unittest.TestCase): 9 | def setUp(self): 10 | self.expectedExec = [] 11 | def check(actual): 12 | self.assert_(len(self.expectedExec), actual) 13 | expected, out = self.expectedExec.pop(0) 14 | self.assertEquals(expected, actual) 15 | return out 16 | def mockPopen(exe, cmd, cwd, env=None, **args): 17 | cmd.insert(0, exe) 18 | return check(cmd) 19 | def mockWrite(file, blob): 20 | self.assertEquals(check(file), blob) 21 | common.popen = mockPopen 22 | common._write = mockWrite 23 | os.makedirs(common.CC_DIR) 24 | def tearDown(self): 25 | shutil.rmtree(common.CC_DIR) 26 | -------------------------------------------------------------------------------- /tests/copy-data/a.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "a". 2 | -------------------------------------------------------------------------------- /tests/output-as-set-data/a.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "a". 2 | -------------------------------------------------------------------------------- /tests/output-as-set-data/b.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "b". 2 | -------------------------------------------------------------------------------- /tests/print_dir.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import sys 5 | 6 | if __name__ == '__main__': 7 | 8 | for f in os.listdir(sys.argv[1]): 9 | print(f) 10 | -------------------------------------------------------------------------------- /tests/sync-config/gitcc: -------------------------------------------------------------------------------- 1 | [core] 2 | ignore_private_files=True -------------------------------------------------------------------------------- /tests/sync-config/gitcc-empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charleso/git-cc/4ff7f908756acef6e1374ef15611ffb4dc2d7322/tests/sync-config/gitcc-empty -------------------------------------------------------------------------------- /tests/sync-data/simple-tree/a.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "a". 2 | -------------------------------------------------------------------------------- /tests/sync-data/simple-tree/lost+found/c.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "c". 2 | -------------------------------------------------------------------------------- /tests/sync-data/simple-tree/subdir/b.txt: -------------------------------------------------------------------------------- 1 | This is a document about the letter "b". 2 | -------------------------------------------------------------------------------- /tests/test-cache.py: -------------------------------------------------------------------------------- 1 | import sys, shutil 2 | sys.path.append("..") 3 | from os.path import join 4 | import unittest 5 | import cache 6 | from cache import Cache, CCFile 7 | import tempfile 8 | 9 | TEMP1 = """ 10 | file.py@@/main/a/b/1 11 | """ 12 | 13 | TEMP1_EXPECTED = """file.py@@/main/a/b/2 14 | file2.py@@/main/c/2 15 | """ 16 | 17 | class CacheTest(unittest.TestCase): 18 | def testLoad(self): 19 | dir = tempfile.mkdtemp() 20 | f = open(join(dir, cache.FILE), 'w') 21 | f.write(TEMP1) 22 | f.close() 23 | try: 24 | c = Cache(dir) 25 | self.assertFalse(c.isChild(CCFile('file.py', '/main/a/1'))) 26 | self.assertFalse(c.isChild(CCFile('file.py', r'\main\a\1'))) 27 | self.assertTrue(c.isChild(CCFile('file.py', '/main/a/b/c/1'))) 28 | self.assertFalse(c.isChild(CCFile('file.py', '/main/a/c/1'))) 29 | c.update(CCFile('file.py', '/main/a/b/2')) 30 | c.update(CCFile('file2.py', '/main/c/2')) 31 | c.write() 32 | f = open(join(dir, cache.FILE), 'r') 33 | try: 34 | self.assertEqual(TEMP1_EXPECTED, f.read()) 35 | finally: 36 | f.close() 37 | finally: 38 | shutil.rmtree(dir) 39 | 40 | if __name__ == "__main__": 41 | unittest.main() 42 | -------------------------------------------------------------------------------- /tests/test-checkin.py: -------------------------------------------------------------------------------- 1 | from __init__ import * 2 | import checkin, common 3 | import unittest, os 4 | from os.path import join 5 | from common import CC_DIR, CI_TAG 6 | 7 | class CheckinTest(TestCaseEx): 8 | def setUp(self): 9 | TestCaseEx.setUp(self) 10 | self.expectedExec.append((['cleartool', 'update', '.'], '')) 11 | self.commits = [] 12 | def checkin(self): 13 | self.expectedExec.insert(1, 14 | (['git', 'log', '--first-parent', '--reverse', '--pretty=format:%H%n%s%n%b', '%s..' % CI_TAG], '\n'.join(self.commits)), 15 | ) 16 | checkin.main() 17 | self.assert_(not len(self.expectedExec)) 18 | def commit(self, commit, message, files): 19 | nameStatus = [] 20 | for type, file in files: 21 | nameStatus.append('%s\0%s' % (type, file)) 22 | self.expectedExec.extend([ 23 | (['git', 'diff', '--name-status', '-M', '-z', '%s^..%s' % (commit, commit)], '\n'.join(nameStatus)), 24 | ]) 25 | types = {'M': MockModfy, 'A': MockAdd, 'D': MockDelete, 'R': MockRename} 26 | self.expectedExec.extend([ 27 | (['git', 'merge-base', CI_TAG, 'HEAD'], 'abcdef'), 28 | ]) 29 | for type, file in files: 30 | types[type](self.expectedExec, commit, message, file) 31 | self.expectedExec.extend([ 32 | (['git', 'tag', '-f', CI_TAG, commit], ''), 33 | ]) 34 | self.commits.extend([commit, message, '']) 35 | def testEmpty(self): 36 | self.checkin() 37 | def testSimple(self): 38 | self.commit('sha1', 'commit1', [('M', 'a.py')]) 39 | self.commit('sha2', 'commit2', [('M', 'b.py')]) 40 | self.commit('sha3', 'commit3', [('A', 'c.py')]) 41 | self.checkin(); 42 | def testFolderAdd(self): 43 | self.commit('sha4', 'commit4', [('A', 'a/b/c/d.py')]) 44 | self.checkin(); 45 | def testDelete(self): 46 | os.mkdir(join(CC_DIR, 'd')) 47 | self.commit('sha4', 'commit4', [('D', 'd/e.py')]) 48 | self.checkin(); 49 | def testRename(self): 50 | os.mkdir(join(CC_DIR, 'a')) 51 | self.commit('sha1', 'commit1', [('R', 'a/b.py\0c/d.py')]) 52 | self.checkin(); 53 | 54 | class MockStatus: 55 | def lsTree(self, id, file, hash): 56 | return (['git', 'ls-tree', '-z', id, file], '100644 blob %s %s' % (hash, file)) 57 | def catFile(self, file, hash): 58 | blob = "blob" 59 | return [ 60 | (['git', 'cat-file', 'blob', hash], blob), 61 | (join(CC_DIR, file), blob), 62 | ] 63 | def hash(self, file): 64 | hash1 = 'hash1' 65 | return [ 66 | (['git', 'hash-object', join(CC_DIR, file)], hash1 + '\n'), 67 | self.lsTree('abcdef', file, hash1), 68 | ] 69 | def co(self, file): 70 | return (['cleartool', 'co', '-reserved', '-nc', file], '') 71 | def ci(self, message, file): 72 | return (['cleartool', 'ci', '-identical', '-c', message, file], '') 73 | def mkelem(self, file): 74 | return (['cleartool', 'mkelem', '-nc', '-eltype', 'directory', file], '') 75 | def dir(self, file): 76 | return file[0:file.rfind('/')]; 77 | 78 | class MockModfy(MockStatus): 79 | def __init__(self, e, commit, message, file): 80 | hash2 = "hash2" 81 | e.append(self.co(file)) 82 | e.extend(self.hash(file)) 83 | e.append(self.lsTree(commit, file, hash2)) 84 | e.extend(self.catFile(file, hash2)) 85 | e.append(self.ci(message, file)) 86 | 87 | class MockAdd(MockStatus): 88 | def __init__(self, e, commit, message, file): 89 | hash = 'hash' 90 | files = [] 91 | files.append(".") 92 | e.append(self.co(".")) 93 | path = "" 94 | for f in file.split('/')[0:-1]: 95 | path = path + f + '/' 96 | f = path[0:-1] 97 | files.append(f) 98 | e.append(self.mkelem(f)) 99 | e.append(self.lsTree(commit, file, hash)) 100 | e.extend(self.catFile(file, hash)) 101 | e.append((['cleartool', 'mkelem', '-nc', file], '.')) 102 | for f in files: 103 | e.append(self.ci(message, f)) 104 | e.append(self.ci(message, file)) 105 | 106 | class MockDelete(MockStatus): 107 | def __init__(self, e, commit, message, file): 108 | dir = file[0:file.rfind('/')] 109 | e.extend([ 110 | self.co(dir), 111 | (['cleartool', 'rm', file], ''), 112 | self.ci(message, dir), 113 | ]) 114 | 115 | class MockRename(MockStatus): 116 | def __init__(self, e, commit, message, file): 117 | a, b = file.split('\0') 118 | hash = 'hash' 119 | e.extend([ 120 | self.co(self.dir(a)), 121 | self.co(a), 122 | ]) 123 | e.extend(self.hash(a)) 124 | e.extend([ 125 | self.co("."), 126 | self.mkelem(self.dir(b)), 127 | (['cleartool', 'mv', '-nc', a, b], '.'), 128 | self.lsTree(commit, b, hash), 129 | ]) 130 | e.extend(self.catFile(b, hash)) 131 | e.extend([ 132 | self.ci(message, self.dir(a)), 133 | self.ci(message, "."), 134 | self.ci(message, self.dir(b)), 135 | self.ci(message, b), 136 | ]) 137 | 138 | if __name__ == "__main__": 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pkgutil 3 | import subprocess 4 | import sys 5 | import unittest 6 | 7 | 8 | class ImportTestSuite(unittest.TestCase): 9 | 10 | def test_import_of_each_module(self): 11 | """Test whether the import each module of package git_cc succeeds. 12 | 13 | This test was added because the import Python 3 requires you to use the 14 | relative package import syntax "from . import " to import 15 | packages from the same package, whereas Python 2 you could just use 16 | "import ". 17 | 18 | """ 19 | import git_cc 20 | package_dir = os.path.dirname(os.path.abspath(git_cc.__file__)) 21 | for _, module_name, _ in pkgutil.iter_modules([package_dir]): 22 | self.check_module_import(package_dir, module_name) 23 | 24 | def check_module_import(self, package_dir, module_name): 25 | """Return true if and only if the import of the given module succeeds. 26 | 27 | If the import fails, this method throws an exception. 28 | """ 29 | 30 | # To test whether the given module can be imported, we start a new 31 | # Python process and let that process import the module. In this way 32 | # the current Python process is not affected by these trial imports. 33 | # 34 | # An earlier version of this test used the Python library 'imp' and 35 | # function 'load_source' to load the given module in the current Python 36 | # process. This lead to conflicts with the actual import of these 37 | # modules. 38 | 39 | full_module_name = "git_cc." + module_name 40 | subprocess.check_call( 41 | [sys.executable, "-c", "import " + full_module_name]) 42 | -------------------------------------------------------------------------------- /tests/test_print_version.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | from contextlib import contextmanager 5 | from git_cc import __version__ 6 | from git_cc import version 7 | 8 | if sys.version_info[0] == 2: 9 | from StringIO import StringIO 10 | else: 11 | from io import StringIO 12 | 13 | 14 | @contextmanager 15 | def redirect_stdout(): 16 | """Redirect sys.stdout to a StringIO and yield that StringIO. 17 | 18 | This function is a context manager that resets the redirect of sys.stdout 19 | to its original value on exit. 20 | 21 | Note that Python 3.4 has this function out-of-the-box, see 22 | contextlib.redirect_stdout. However, we also have to run using Python 2.x. 23 | 24 | """ 25 | stdout = sys.stdout 26 | sys.stdout = StringIO() 27 | yield sys.stdout 28 | sys.stdout = stdout 29 | 30 | 31 | class PrintVersionTestSuite(unittest.TestCase): 32 | 33 | def test_printed_version_is_the_correct_version(self): 34 | 35 | with redirect_stdout() as redirect: 36 | version.main() 37 | 38 | # do not forget to strip the newline from the redirected output 39 | self.assertEqual(__version__, redirect.getvalue().rstrip()) 40 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | import filecmp 2 | import os 3 | import shutil 4 | import sys 5 | import stat 6 | import unittest 7 | 8 | from git_cc.common import GitConfigParser 9 | from git_cc.sync import Sync 10 | from git_cc.sync import SyncFile 11 | from git_cc.sync import ClearCaseSync 12 | from git_cc.sync import output_as_set 13 | 14 | if sys.version_info[0] == 2: 15 | from mock import Mock 16 | elif sys.version_info[0] == 3: 17 | if sys.version_info[1] < 3: 18 | from mock import Mock 19 | else: 20 | from unittest.mock import Mock 21 | 22 | _current_dir = os.path.dirname(__file__) 23 | 24 | 25 | class CopyTestSuite(unittest.TestCase): 26 | 27 | def setUp(self): 28 | 29 | self.clear_filecmp_cache() 30 | 31 | current_dir = os.path.dirname(os.path.abspath(__file__)) 32 | self.src_dir = os.path.join(current_dir, "copy-data") 33 | self.dst_dir = os.path.join(current_dir, "sandbox") 34 | 35 | if os.path.exists(self.dst_dir): 36 | shutil.rmtree(self.dst_dir) 37 | 38 | os.mkdir(self.dst_dir) 39 | 40 | def tearDown(self): 41 | shutil.rmtree(self.dst_dir) 42 | 43 | def test_copy_creates_new_file(self): 44 | 45 | fileName = "a.txt" 46 | 47 | copyIsDone = SyncFile().do_sync( 48 | fileName, src_dir=self.src_dir, dst_dir=self.dst_dir) 49 | self.assertTrue(copyIsDone) 50 | src_path = os.path.join(self.src_dir, fileName) 51 | dst_path = os.path.join(self.dst_dir, fileName) 52 | self.files_are_equal(src_path, dst_path) 53 | 54 | def test_copy_overwrites_existing_different_file(self): 55 | 56 | fileName = "a.txt" 57 | 58 | src_path = os.path.join(self.src_dir, fileName) 59 | with open(src_path, "r") as f: 60 | lines = f.readlines() 61 | lines[0] = lines[0].replace('e', 'f') 62 | 63 | dst_path = os.path.join(self.dst_dir, fileName) 64 | with open(dst_path, "w") as f: 65 | f.writelines(lines) 66 | 67 | # to make it more difficult, we give the destination file the same 68 | # file stats 69 | shutil.copystat(src_path, dst_path) 70 | 71 | copyIsDone = SyncFile().do_sync( 72 | fileName, src_dir=self.src_dir, dst_dir=self.dst_dir) 73 | self.assertTrue(copyIsDone) 74 | self.files_are_equal(src_path, dst_path) 75 | 76 | def test_copy_does_not_overwrite_equal_file(self): 77 | 78 | fileName = "a.txt" 79 | 80 | src_path = os.path.join(self.src_dir, fileName) 81 | dst_path = os.path.join(self.dst_dir, fileName) 82 | 83 | shutil.copyfile(src_path, dst_path) 84 | self.assertTrue(os.path.exists(dst_path)) 85 | 86 | # We make the destination file read-only. If the copy statement throws 87 | # an exception, it did not recognize that the destination file was the 88 | # same and tried to copy it. 89 | os.chmod(dst_path, stat.S_IREAD) 90 | 91 | copyIsDone = SyncFile().do_sync( 92 | fileName, src_dir=self.src_dir, dst_dir=self.dst_dir) 93 | self.assertFalse(copyIsDone) 94 | 95 | def files_are_equal(self, src_path, dst_path): 96 | 97 | self.clear_filecmp_cache() 98 | 99 | self.assertTrue(filecmp.cmp(src_path, dst_path)) 100 | 101 | src_stats = os.stat(src_path) 102 | dst_stats = os.stat(dst_path) 103 | self.assertAlmostEqual(src_stats.st_mtime, dst_stats.st_mtime, 104 | places=2) 105 | 106 | def clear_filecmp_cache(self): 107 | """Clear the cache of module filecmp to trigger new file comparisons. 108 | 109 | Module filecmp keeps a cache of file comparisons so it does not have to 110 | recompare files whose stats have not changed. This disrupts tests in 111 | this suite which compare files with the same paths and stats but with 112 | different contents. For that reason, we can clear the cache. 113 | 114 | Do note that this function uses an internal variable of filecmp and can 115 | break when that variable is removed, renamed etc. 116 | 117 | """ 118 | filecmp._cache = {} 119 | 120 | 121 | class SyncTestSuite(unittest.TestCase): 122 | 123 | def setUp(self): 124 | 125 | self.current_dir = os.path.dirname(os.path.abspath(__file__)) 126 | self.dst_root = os.path.join(self.current_dir, "sandbox") 127 | 128 | if os.path.exists(self.dst_root): 129 | shutil.rmtree(self.dst_root) 130 | 131 | os.mkdir(self.dst_root) 132 | 133 | def tearDown(self): 134 | shutil.rmtree(self.dst_root) 135 | 136 | def test_sync_copies_directory_tree(self): 137 | 138 | self.src_root = os.path.join(self.current_dir, "sync-data/simple-tree") 139 | src_dirs = ["."] 140 | 141 | sync = Sync(self.src_root, src_dirs, self.dst_root) 142 | sync.do_sync() 143 | 144 | dircmp = filecmp.dircmp(self.src_root, self.dst_root) 145 | 146 | self.assertEqual(dircmp.left_only, []) 147 | self.assertEqual(dircmp.right_only, []) 148 | self.assertEqual(dircmp.diff_files, []) 149 | 150 | def test_clearcase_sync_copies_directory_tree(self): 151 | 152 | self.src_root = os.path.join( 153 | self.current_dir, "sync-data", "simple-tree") 154 | src_dirs = ["."] 155 | 156 | sync = ClearCaseSync(self.src_root, src_dirs, self.dst_root) 157 | sync.collect_private_files = Mock(return_value={}) 158 | sync.do_sync() 159 | 160 | dircmp = filecmp.dircmp(self.src_root, self.dst_root) 161 | 162 | self.assertEqual(dircmp.left_only, ['lost+found']) 163 | self.assertEqual(dircmp.right_only, []) 164 | self.assertEqual(dircmp.diff_files, []) 165 | 166 | def test_clearcase_sync_copies_directory_tree_without_private_files(self): 167 | 168 | self.src_root = os.path.join( 169 | self.current_dir, "sync-data", "simple-tree") 170 | src_dirs = ["."] 171 | 172 | sync = ClearCaseSync(self.src_root, src_dirs, self.dst_root) 173 | private_file = os.path.join(self.src_root, "subdir", "b.txt") 174 | sync.collect_private_files = Mock(return_value={private_file: 1}) 175 | sync.do_sync() 176 | 177 | dircmp = filecmp.dircmp(self.src_root, self.dst_root) 178 | 179 | self.assertEqual( 180 | sorted(dircmp.left_only), sorted(["lost+found", "subdir"])) 181 | self.assertEqual(dircmp.right_only, []) 182 | self.assertEqual(dircmp.diff_files, []) 183 | 184 | 185 | class CollectCommandOutputSuite(unittest.TestCase): 186 | 187 | def test_collect_output(self): 188 | 189 | current_dir = os.path.dirname(os.path.abspath(__file__)) 190 | module = os.path.join(current_dir, "print_dir.py") 191 | directory = os.path.join(current_dir, "output-as-set-data") 192 | contents = output_as_set([sys.executable, module, directory]) 193 | 194 | self.assertEqual(set(["a.txt", "b.txt"]), contents) 195 | 196 | 197 | class SyncConfigTestSuite(unittest.TestCase): 198 | 199 | def test_retrieval_of_setting_using_config(self): 200 | 201 | gitcc_config_path = self.get_path_to("gitcc") 202 | 203 | cfg = GitConfigParser("don't care section", gitcc_config_path) 204 | cfg.read() 205 | 206 | self.assertTrue(cfg.ignorePrivateFiles()) 207 | 208 | def test_retrieval_of_setting_using_empty_config(self): 209 | 210 | gitcc_config_path = self.get_path_to("gitcc-empty") 211 | 212 | cfg = GitConfigParser("don't care section", gitcc_config_path) 213 | cfg.read() 214 | 215 | self.assertFalse(cfg.ignorePrivateFiles()) 216 | 217 | def get_path_to(self, file_name): 218 | """Return the path to the given file in directory "sync-config". 219 | 220 | Directory "sync-config" is located in the same directory as the current 221 | file. 222 | 223 | """ 224 | return os.path.join(_current_dir, "sync-config", file_name) 225 | -------------------------------------------------------------------------------- /tests/test_users_module_import.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from git_cc.common import get_users_module 5 | from git_cc.common import GitConfigParser 6 | 7 | _current_dir = os.path.dirname(__file__) 8 | 9 | 10 | class UsersModuleImportTestSuite(unittest.TestCase): 11 | 12 | def test_import_of_users_module(self): 13 | 14 | users_module_path = self.get_path_to("users.py") 15 | users = get_users_module(users_module_path) 16 | 17 | self.assertEqual(users.users["charleso"], "Charles O'Farrell") 18 | self.assertEqual(users.users["jki"], "Jan Kiszka ") 19 | self.assertEqual(users.mailSuffix, "example.com") 20 | 21 | def test_import_of_nonexisting_users_module(self): 22 | 23 | users_module_path = self.get_path_to("nonexisting.py") 24 | users = get_users_module(users_module_path) 25 | 26 | self.assertEqual(users.users, {}) 27 | self.assertEqual(users.mailSuffix, "") 28 | 29 | def test_import_of_unspecified_users_module(self): 30 | 31 | users = get_users_module("") 32 | 33 | self.assertEqual(users.users, {}) 34 | self.assertEqual(users.mailSuffix, "") 35 | 36 | def test_retrieval_of_absolute_users_module_path(self): 37 | 38 | gitcc_config_path = self.get_path_to("gitcc-abs") 39 | 40 | cfg = GitConfigParser("don't care section", gitcc_config_path) 41 | cfg.read() 42 | 43 | abs_path = "/home/user/gitcc/users.py" 44 | self.assertEqual(abs_path, cfg.getUsersModulePath()) 45 | 46 | def test_retrieval_of_relative_users_module_path(self): 47 | 48 | gitcc_config_path = self.get_path_to("gitcc") 49 | 50 | cfg = GitConfigParser("don't care section", gitcc_config_path) 51 | cfg.read() 52 | 53 | abs_path = os.path.join(_current_dir, "user-config", "users.py") 54 | self.assertEqual(abs_path, cfg.getUsersModulePath()) 55 | 56 | def test_retrieval_of_users_using_config(self): 57 | 58 | gitcc_config_path = self.get_path_to("gitcc") 59 | 60 | cfg = GitConfigParser("don't care section", gitcc_config_path) 61 | cfg.read() 62 | 63 | users = get_users_module(cfg.getUsersModulePath()) 64 | 65 | self.assertEqual(users.users["charleso"], "Charles O'Farrell") 66 | self.assertEqual(users.users["jki"], "Jan Kiszka ") 67 | self.assertEqual(users.mailSuffix, "example.com") 68 | 69 | def test_retrieval_of_users_using_empty_config(self): 70 | 71 | gitcc_config_path = self.get_path_to("gitcc-empty") 72 | 73 | cfg = GitConfigParser("don't care section", gitcc_config_path) 74 | cfg.read() 75 | 76 | users = get_users_module(cfg.getUsersModulePath()) 77 | 78 | self.assertEqual(users.users, {}) 79 | self.assertEqual(users.mailSuffix, "") 80 | 81 | def get_path_to(self, file_name): 82 | """Return the path to the given file in directory "user-config". 83 | 84 | Directory "user-config" is located in the same directory as the current 85 | file. 86 | 87 | """ 88 | return os.path.join(_current_dir, "user-config", file_name) 89 | -------------------------------------------------------------------------------- /tests/user-config/gitcc: -------------------------------------------------------------------------------- 1 | [core] 2 | users_module_path: users.py -------------------------------------------------------------------------------- /tests/user-config/gitcc-abs: -------------------------------------------------------------------------------- 1 | [core] 2 | users_module_path: /home/user/gitcc/users.py -------------------------------------------------------------------------------- /tests/user-config/gitcc-empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charleso/git-cc/4ff7f908756acef6e1374ef15611ffb4dc2d7322/tests/user-config/gitcc-empty -------------------------------------------------------------------------------- /tests/user-config/users.py: -------------------------------------------------------------------------------- 1 | users = { 2 | 'charleso': "Charles O'Farrell",\ 3 | 'jki': 'Jan Kiszka ',\ 4 | } 5 | 6 | mailSuffix = 'example.com' 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35 3 | [testenv] 4 | commands=python -m unittest discover tests/ 5 | [testenv:py27] 6 | deps=mock 7 | --------------------------------------------------------------------------------