├── README.md ├── LICENSE.txt └── pygit.py /README.md: -------------------------------------------------------------------------------- 1 | 2 | pygit 3 | ===== 4 | 5 | pygit is just enough git (implemented in Python) to create a repo and push to GitHub. It's about 500 lines of Python 3 code that can create a repository, add files to the git index, commit, and push to GitHub. 6 | 7 | This repo was created and pushed to GitHub using pygit. [Read the full story here.](http://benhoyt.com/writings/pygit/) 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ben Hoyt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pygit.py: -------------------------------------------------------------------------------- 1 | """Implement just enough git to commit and push to GitHub. 2 | 3 | Read the story here: http://benhoyt.com/writings/pygit/ 4 | 5 | Released under a permissive MIT license (see LICENSE.txt). 6 | """ 7 | 8 | import argparse, collections, difflib, enum, hashlib, operator, os, stat 9 | import struct, sys, time, urllib.request, zlib 10 | 11 | 12 | # Data for one entry in the git index (.git/index) 13 | IndexEntry = collections.namedtuple('IndexEntry', [ 14 | 'ctime_s', 'ctime_n', 'mtime_s', 'mtime_n', 'dev', 'ino', 'mode', 'uid', 15 | 'gid', 'size', 'sha1', 'flags', 'path', 16 | ]) 17 | 18 | 19 | class ObjectType(enum.Enum): 20 | """Object type enum. There are other types too, but we don't need them. 21 | See "enum object_type" in git's source (git/cache.h). 22 | """ 23 | commit = 1 24 | tree = 2 25 | blob = 3 26 | 27 | 28 | def read_file(path): 29 | """Read contents of file at given path as bytes.""" 30 | with open(path, 'rb') as f: 31 | return f.read() 32 | 33 | 34 | def write_file(path, data): 35 | """Write data bytes to file at given path.""" 36 | with open(path, 'wb') as f: 37 | f.write(data) 38 | 39 | 40 | def init(repo): 41 | """Create directory for repo and initialize .git directory.""" 42 | os.mkdir(repo) 43 | os.mkdir(os.path.join(repo, '.git')) 44 | for name in ['objects', 'refs', 'refs/heads']: 45 | os.mkdir(os.path.join(repo, '.git', name)) 46 | write_file(os.path.join(repo, '.git', 'HEAD'), b'ref: refs/heads/master') 47 | print('initialized empty repository: {}'.format(repo)) 48 | 49 | 50 | def hash_object(data, obj_type, write=True): 51 | """Compute hash of object data of given type and write to object store if 52 | "write" is True. Return SHA-1 object hash as hex string. 53 | """ 54 | header = '{} {}'.format(obj_type, len(data)).encode() 55 | full_data = header + b'\x00' + data 56 | sha1 = hashlib.sha1(full_data).hexdigest() 57 | if write: 58 | path = os.path.join('.git', 'objects', sha1[:2], sha1[2:]) 59 | if not os.path.exists(path): 60 | os.makedirs(os.path.dirname(path), exist_ok=True) 61 | write_file(path, zlib.compress(full_data)) 62 | return sha1 63 | 64 | 65 | def find_object(sha1_prefix): 66 | """Find object with given SHA-1 prefix and return path to object in object 67 | store, or raise ValueError if there are no objects or multiple objects 68 | with this prefix. 69 | """ 70 | if len(sha1_prefix) < 2: 71 | raise ValueError('hash prefix must be 2 or more characters') 72 | obj_dir = os.path.join('.git', 'objects', sha1_prefix[:2]) 73 | rest = sha1_prefix[2:] 74 | objects = [name for name in os.listdir(obj_dir) if name.startswith(rest)] 75 | if not objects: 76 | raise ValueError('object {!r} not found'.format(sha1_prefix)) 77 | if len(objects) >= 2: 78 | raise ValueError('multiple objects ({}) with prefix {!r}'.format( 79 | len(objects), sha1_prefix)) 80 | return os.path.join(obj_dir, objects[0]) 81 | 82 | 83 | def read_object(sha1_prefix): 84 | """Read object with given SHA-1 prefix and return tuple of 85 | (object_type, data_bytes), or raise ValueError if not found. 86 | """ 87 | path = find_object(sha1_prefix) 88 | full_data = zlib.decompress(read_file(path)) 89 | nul_index = full_data.index(b'\x00') 90 | header = full_data[:nul_index] 91 | obj_type, size_str = header.decode().split() 92 | size = int(size_str) 93 | data = full_data[nul_index + 1:] 94 | assert size == len(data), 'expected size {}, got {} bytes'.format( 95 | size, len(data)) 96 | return (obj_type, data) 97 | 98 | 99 | def cat_file(mode, sha1_prefix): 100 | """Write the contents of (or info about) object with given SHA-1 prefix to 101 | stdout. If mode is 'commit', 'tree', or 'blob', print raw data bytes of 102 | object. If mode is 'size', print the size of the object. If mode is 103 | 'type', print the type of the object. If mode is 'pretty', print a 104 | prettified version of the object. 105 | """ 106 | obj_type, data = read_object(sha1_prefix) 107 | if mode in ['commit', 'tree', 'blob']: 108 | if obj_type != mode: 109 | raise ValueError('expected object type {}, got {}'.format( 110 | mode, obj_type)) 111 | sys.stdout.buffer.write(data) 112 | elif mode == 'size': 113 | print(len(data)) 114 | elif mode == 'type': 115 | print(obj_type) 116 | elif mode == 'pretty': 117 | if obj_type in ['commit', 'blob']: 118 | sys.stdout.buffer.write(data) 119 | elif obj_type == 'tree': 120 | for mode, path, sha1 in read_tree(data=data): 121 | type_str = 'tree' if stat.S_ISDIR(mode) else 'blob' 122 | print('{:06o} {} {}\t{}'.format(mode, type_str, sha1, path)) 123 | else: 124 | assert False, 'unhandled object type {!r}'.format(obj_type) 125 | else: 126 | raise ValueError('unexpected mode {!r}'.format(mode)) 127 | 128 | 129 | def read_index(): 130 | """Read git index file and return list of IndexEntry objects.""" 131 | try: 132 | data = read_file(os.path.join('.git', 'index')) 133 | except FileNotFoundError: 134 | return [] 135 | digest = hashlib.sha1(data[:-20]).digest() 136 | assert digest == data[-20:], 'invalid index checksum' 137 | signature, version, num_entries = struct.unpack('!4sLL', data[:12]) 138 | assert signature == b'DIRC', \ 139 | 'invalid index signature {}'.format(signature) 140 | assert version == 2, 'unknown index version {}'.format(version) 141 | entry_data = data[12:-20] 142 | entries = [] 143 | i = 0 144 | while i + 62 < len(entry_data): 145 | fields_end = i + 62 146 | fields = struct.unpack('!LLLLLLLLLL20sH', entry_data[i:fields_end]) 147 | path_end = entry_data.index(b'\x00', fields_end) 148 | path = entry_data[fields_end:path_end] 149 | entry = IndexEntry(*(fields + (path.decode(),))) 150 | entries.append(entry) 151 | entry_len = ((62 + len(path) + 8) // 8) * 8 152 | i += entry_len 153 | assert len(entries) == num_entries 154 | return entries 155 | 156 | 157 | def ls_files(details=False): 158 | """Print list of files in index (including mode, SHA-1, and stage number 159 | if "details" is True). 160 | """ 161 | for entry in read_index(): 162 | if details: 163 | stage = (entry.flags >> 12) & 3 164 | print('{:6o} {} {:}\t{}'.format( 165 | entry.mode, entry.sha1.hex(), stage, entry.path)) 166 | else: 167 | print(entry.path) 168 | 169 | 170 | def get_status(): 171 | """Get status of working copy, return tuple of (changed_paths, new_paths, 172 | deleted_paths). 173 | """ 174 | paths = set() 175 | for root, dirs, files in os.walk('.'): 176 | dirs[:] = [d for d in dirs if d != '.git'] 177 | for file in files: 178 | path = os.path.join(root, file) 179 | path = path.replace('\\', '/') 180 | if path.startswith('./'): 181 | path = path[2:] 182 | paths.add(path) 183 | entries_by_path = {e.path: e for e in read_index()} 184 | entry_paths = set(entries_by_path) 185 | changed = {p for p in (paths & entry_paths) 186 | if hash_object(read_file(p), 'blob', write=False) != 187 | entries_by_path[p].sha1.hex()} 188 | new = paths - entry_paths 189 | deleted = entry_paths - paths 190 | return (sorted(changed), sorted(new), sorted(deleted)) 191 | 192 | 193 | def status(): 194 | """Show status of working copy.""" 195 | changed, new, deleted = get_status() 196 | if changed: 197 | print('changed files:') 198 | for path in changed: 199 | print(' ', path) 200 | if new: 201 | print('new files:') 202 | for path in new: 203 | print(' ', path) 204 | if deleted: 205 | print('deleted files:') 206 | for path in deleted: 207 | print(' ', path) 208 | 209 | 210 | def diff(): 211 | """Show diff of files changed (between index and working copy).""" 212 | changed, _, _ = get_status() 213 | entries_by_path = {e.path: e for e in read_index()} 214 | for i, path in enumerate(changed): 215 | sha1 = entries_by_path[path].sha1.hex() 216 | obj_type, data = read_object(sha1) 217 | assert obj_type == 'blob' 218 | index_lines = data.decode().splitlines() 219 | working_lines = read_file(path).decode().splitlines() 220 | diff_lines = difflib.unified_diff( 221 | index_lines, working_lines, 222 | '{} (index)'.format(path), 223 | '{} (working copy)'.format(path), 224 | lineterm='') 225 | for line in diff_lines: 226 | print(line) 227 | if i < len(changed) - 1: 228 | print('-' * 70) 229 | 230 | 231 | def write_index(entries): 232 | """Write list of IndexEntry objects to git index file.""" 233 | packed_entries = [] 234 | for entry in entries: 235 | entry_head = struct.pack('!LLLLLLLLLL20sH', 236 | entry.ctime_s, entry.ctime_n, entry.mtime_s, entry.mtime_n, 237 | entry.dev, entry.ino, entry.mode, entry.uid, entry.gid, 238 | entry.size, entry.sha1, entry.flags) 239 | path = entry.path.encode() 240 | length = ((62 + len(path) + 8) // 8) * 8 241 | packed_entry = entry_head + path + b'\x00' * (length - 62 - len(path)) 242 | packed_entries.append(packed_entry) 243 | header = struct.pack('!4sLL', b'DIRC', 2, len(entries)) 244 | all_data = header + b''.join(packed_entries) 245 | digest = hashlib.sha1(all_data).digest() 246 | write_file(os.path.join('.git', 'index'), all_data + digest) 247 | 248 | 249 | def add(paths): 250 | """Add all file paths to git index.""" 251 | paths = [p.replace('\\', '/') for p in paths] 252 | all_entries = read_index() 253 | entries = [e for e in all_entries if e.path not in paths] 254 | for path in paths: 255 | sha1 = hash_object(read_file(path), 'blob') 256 | st = os.stat(path) 257 | flags = len(path.encode()) 258 | assert flags < (1 << 12) 259 | entry = IndexEntry( 260 | int(st.st_ctime), 0, int(st.st_mtime), 0, st.st_dev, 261 | st.st_ino, st.st_mode, st.st_uid, st.st_gid, st.st_size, 262 | bytes.fromhex(sha1), flags, path) 263 | entries.append(entry) 264 | entries.sort(key=operator.attrgetter('path')) 265 | write_index(entries) 266 | 267 | 268 | def write_tree(): 269 | """Write a tree object from the current index entries.""" 270 | tree_entries = [] 271 | for entry in read_index(): 272 | assert '/' not in entry.path, \ 273 | 'currently only supports a single, top-level directory' 274 | mode_path = '{:o} {}'.format(entry.mode, entry.path).encode() 275 | tree_entry = mode_path + b'\x00' + entry.sha1 276 | tree_entries.append(tree_entry) 277 | return hash_object(b''.join(tree_entries), 'tree') 278 | 279 | 280 | def get_local_master_hash(): 281 | """Get current commit hash (SHA-1 string) of local master branch.""" 282 | master_path = os.path.join('.git', 'refs', 'heads', 'master') 283 | try: 284 | return read_file(master_path).decode().strip() 285 | except FileNotFoundError: 286 | return None 287 | 288 | 289 | def commit(message, author=None): 290 | """Commit the current state of the index to master with given message. 291 | Return hash of commit object. 292 | """ 293 | tree = write_tree() 294 | parent = get_local_master_hash() 295 | if author is None: 296 | author = '{} <{}>'.format( 297 | os.environ['GIT_AUTHOR_NAME'], os.environ['GIT_AUTHOR_EMAIL']) 298 | timestamp = int(time.mktime(time.localtime())) 299 | utc_offset = -time.timezone 300 | author_time = '{} {}{:02}{:02}'.format( 301 | timestamp, 302 | '+' if utc_offset > 0 else '-', 303 | abs(utc_offset) // 3600, 304 | (abs(utc_offset) // 60) % 60) 305 | lines = ['tree ' + tree] 306 | if parent: 307 | lines.append('parent ' + parent) 308 | lines.append('author {} {}'.format(author, author_time)) 309 | lines.append('committer {} {}'.format(author, author_time)) 310 | lines.append('') 311 | lines.append(message) 312 | lines.append('') 313 | data = '\n'.join(lines).encode() 314 | sha1 = hash_object(data, 'commit') 315 | master_path = os.path.join('.git', 'refs', 'heads', 'master') 316 | write_file(master_path, (sha1 + '\n').encode()) 317 | print('committed to master: {:7}'.format(sha1)) 318 | return sha1 319 | 320 | 321 | def extract_lines(data): 322 | """Extract list of lines from given server data.""" 323 | lines = [] 324 | i = 0 325 | for _ in range(1000): 326 | line_length = int(data[i:i + 4], 16) 327 | line = data[i + 4:i + line_length] 328 | lines.append(line) 329 | if line_length == 0: 330 | i += 4 331 | else: 332 | i += line_length 333 | if i >= len(data): 334 | break 335 | return lines 336 | 337 | 338 | def build_lines_data(lines): 339 | """Build byte string from given lines to send to server.""" 340 | result = [] 341 | for line in lines: 342 | result.append('{:04x}'.format(len(line) + 5).encode()) 343 | result.append(line) 344 | result.append(b'\n') 345 | result.append(b'0000') 346 | return b''.join(result) 347 | 348 | 349 | def http_request(url, username, password, data=None): 350 | """Make an authenticated HTTP request to given URL (GET by default, POST 351 | if "data" is not None). 352 | """ 353 | password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm() 354 | password_manager.add_password(None, url, username, password) 355 | auth_handler = urllib.request.HTTPBasicAuthHandler(password_manager) 356 | opener = urllib.request.build_opener(auth_handler) 357 | f = opener.open(url, data=data) 358 | return f.read() 359 | 360 | 361 | def get_remote_master_hash(git_url, username, password): 362 | """Get commit hash of remote master branch, return SHA-1 hex string or 363 | None if no remote commits. 364 | """ 365 | url = git_url + '/info/refs?service=git-receive-pack' 366 | response = http_request(url, username, password) 367 | lines = extract_lines(response) 368 | assert lines[0] == b'# service=git-receive-pack\n' 369 | assert lines[1] == b'' 370 | if lines[2][:40] == b'0' * 40: 371 | return None 372 | master_sha1, master_ref = lines[2].split(b'\x00')[0].split() 373 | assert master_ref == b'refs/heads/master' 374 | assert len(master_sha1) == 40 375 | return master_sha1.decode() 376 | 377 | 378 | def read_tree(sha1=None, data=None): 379 | """Read tree object with given SHA-1 (hex string) or data, and return list 380 | of (mode, path, sha1) tuples. 381 | """ 382 | if sha1 is not None: 383 | obj_type, data = read_object(sha1) 384 | assert obj_type == 'tree' 385 | elif data is None: 386 | raise TypeError('must specify "sha1" or "data"') 387 | i = 0 388 | entries = [] 389 | for _ in range(1000): 390 | end = data.find(b'\x00', i) 391 | if end == -1: 392 | break 393 | mode_str, path = data[i:end].decode().split() 394 | mode = int(mode_str, 8) 395 | digest = data[end + 1:end + 21] 396 | entries.append((mode, path, digest.hex())) 397 | i = end + 1 + 20 398 | return entries 399 | 400 | 401 | def find_tree_objects(tree_sha1): 402 | """Return set of SHA-1 hashes of all objects in this tree (recursively), 403 | including the hash of the tree itself. 404 | """ 405 | objects = {tree_sha1} 406 | for mode, path, sha1 in read_tree(sha1=tree_sha1): 407 | if stat.S_ISDIR(mode): 408 | objects.update(find_tree_objects(sha1)) 409 | else: 410 | objects.add(sha1) 411 | return objects 412 | 413 | 414 | def find_commit_objects(commit_sha1): 415 | """Return set of SHA-1 hashes of all objects in this commit (recursively), 416 | its tree, its parents, and the hash of the commit itself. 417 | """ 418 | objects = {commit_sha1} 419 | obj_type, commit = read_object(commit_sha1) 420 | assert obj_type == 'commit' 421 | lines = commit.decode().splitlines() 422 | tree = next(l[5:45] for l in lines if l.startswith('tree ')) 423 | objects.update(find_tree_objects(tree)) 424 | parents = (l[7:47] for l in lines if l.startswith('parent ')) 425 | for parent in parents: 426 | objects.update(find_commit_objects(parent)) 427 | return objects 428 | 429 | 430 | def find_missing_objects(local_sha1, remote_sha1): 431 | """Return set of SHA-1 hashes of objects in local commit that are missing 432 | at the remote (based on the given remote commit hash). 433 | """ 434 | local_objects = find_commit_objects(local_sha1) 435 | if remote_sha1 is None: 436 | return local_objects 437 | remote_objects = find_commit_objects(remote_sha1) 438 | return local_objects - remote_objects 439 | 440 | 441 | def encode_pack_object(obj): 442 | """Encode a single object for a pack file and return bytes (variable- 443 | length header followed by compressed data bytes). 444 | """ 445 | obj_type, data = read_object(obj) 446 | type_num = ObjectType[obj_type].value 447 | size = len(data) 448 | byte = (type_num << 4) | (size & 0x0f) 449 | size >>= 4 450 | header = [] 451 | while size: 452 | header.append(byte | 0x80) 453 | byte = size & 0x7f 454 | size >>= 7 455 | header.append(byte) 456 | return bytes(header) + zlib.compress(data) 457 | 458 | 459 | def create_pack(objects): 460 | """Create pack file containing all objects in given given set of SHA-1 461 | hashes, return data bytes of full pack file. 462 | """ 463 | header = struct.pack('!4sLL', b'PACK', 2, len(objects)) 464 | body = b''.join(encode_pack_object(o) for o in sorted(objects)) 465 | contents = header + body 466 | sha1 = hashlib.sha1(contents).digest() 467 | data = contents + sha1 468 | return data 469 | 470 | 471 | def push(git_url, username=None, password=None): 472 | """Push master branch to given git repo URL.""" 473 | if username is None: 474 | username = os.environ['GIT_USERNAME'] 475 | if password is None: 476 | password = os.environ['GIT_PASSWORD'] 477 | remote_sha1 = get_remote_master_hash(git_url, username, password) 478 | local_sha1 = get_local_master_hash() 479 | missing = find_missing_objects(local_sha1, remote_sha1) 480 | print('updating remote master from {} to {} ({} object{})'.format( 481 | remote_sha1 or 'no commits', local_sha1, len(missing), 482 | '' if len(missing) == 1 else 's')) 483 | lines = ['{} {} refs/heads/master\x00 report-status'.format( 484 | remote_sha1 or ('0' * 40), local_sha1).encode()] 485 | data = build_lines_data(lines) + create_pack(missing) 486 | url = git_url + '/git-receive-pack' 487 | response = http_request(url, username, password, data=data) 488 | lines = extract_lines(response) 489 | assert len(lines) >= 2, \ 490 | 'expected at least 2 lines, got {}'.format(len(lines)) 491 | assert lines[0] == b'unpack ok\n', \ 492 | "expected line 1 b'unpack ok', got: {}".format(lines[0]) 493 | assert lines[1] == b'ok refs/heads/master\n', \ 494 | "expected line 2 b'ok refs/heads/master\n', got: {}".format(lines[1]) 495 | return (remote_sha1, missing) 496 | 497 | 498 | if __name__ == '__main__': 499 | parser = argparse.ArgumentParser() 500 | sub_parsers = parser.add_subparsers(dest='command', metavar='command') 501 | sub_parsers.required = True 502 | 503 | sub_parser = sub_parsers.add_parser('add', 504 | help='add file(s) to index') 505 | sub_parser.add_argument('paths', nargs='+', metavar='path', 506 | help='path(s) of files to add') 507 | 508 | sub_parser = sub_parsers.add_parser('cat-file', 509 | help='display contents of object') 510 | valid_modes = ['commit', 'tree', 'blob', 'size', 'type', 'pretty'] 511 | sub_parser.add_argument('mode', choices=valid_modes, 512 | help='object type (commit, tree, blob) or display mode (size, ' 513 | 'type, pretty)') 514 | sub_parser.add_argument('hash_prefix', 515 | help='SHA-1 hash (or hash prefix) of object to display') 516 | 517 | sub_parser = sub_parsers.add_parser('commit', 518 | help='commit current state of index to master branch') 519 | sub_parser.add_argument('-a', '--author', 520 | help='commit author in format "A U Thor " ' 521 | '(uses GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL environment ' 522 | 'variables by default)') 523 | sub_parser.add_argument('-m', '--message', required=True, 524 | help='text of commit message') 525 | 526 | sub_parser = sub_parsers.add_parser('diff', 527 | help='show diff of files changed (between index and working ' 528 | 'copy)') 529 | 530 | sub_parser = sub_parsers.add_parser('hash-object', 531 | help='hash contents of given path (and optionally write to ' 532 | 'object store)') 533 | sub_parser.add_argument('path', 534 | help='path of file to hash') 535 | sub_parser.add_argument('-t', choices=['commit', 'tree', 'blob'], 536 | default='blob', dest='type', 537 | help='type of object (default %(default)r)') 538 | sub_parser.add_argument('-w', action='store_true', dest='write', 539 | help='write object to object store (as well as printing hash)') 540 | 541 | sub_parser = sub_parsers.add_parser('init', 542 | help='initialize a new repo') 543 | sub_parser.add_argument('repo', 544 | help='directory name for new repo') 545 | 546 | sub_parser = sub_parsers.add_parser('ls-files', 547 | help='list files in index') 548 | sub_parser.add_argument('-s', '--stage', action='store_true', 549 | help='show object details (mode, hash, and stage number) in ' 550 | 'addition to path') 551 | 552 | sub_parser = sub_parsers.add_parser('push', 553 | help='push master branch to given git server URL') 554 | sub_parser.add_argument('git_url', 555 | help='URL of git repo, eg: https://github.com/benhoyt/pygit.git') 556 | sub_parser.add_argument('-p', '--password', 557 | help='password to use for authentication (uses GIT_PASSWORD ' 558 | 'environment variable by default)') 559 | sub_parser.add_argument('-u', '--username', 560 | help='username to use for authentication (uses GIT_USERNAME ' 561 | 'environment variable by default)') 562 | 563 | sub_parser = sub_parsers.add_parser('status', 564 | help='show status of working copy') 565 | 566 | args = parser.parse_args() 567 | if args.command == 'add': 568 | add(args.paths) 569 | elif args.command == 'cat-file': 570 | try: 571 | cat_file(args.mode, args.hash_prefix) 572 | except ValueError as error: 573 | print(error, file=sys.stderr) 574 | sys.exit(1) 575 | elif args.command == 'commit': 576 | commit(args.message, author=args.author) 577 | elif args.command == 'diff': 578 | diff() 579 | elif args.command == 'hash-object': 580 | sha1 = hash_object(read_file(args.path), args.type, write=args.write) 581 | print(sha1) 582 | elif args.command == 'init': 583 | init(args.repo) 584 | elif args.command == 'ls-files': 585 | ls_files(details=args.stage) 586 | elif args.command == 'push': 587 | push(args.git_url, username=args.username, password=args.password) 588 | elif args.command == 'status': 589 | status() 590 | else: 591 | assert False, 'unexpected command {!r}'.format(args.command) 592 | --------------------------------------------------------------------------------