├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.md ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── libtft.py ├── setup.py └── tft /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: Thanks for taking the time to fill out this bug! 7 | - type: textarea 8 | id: repro 9 | attributes: 10 | label: Reproduction steps 11 | description: "How do you trigger this bug? Please walk us through it step by step." 12 | value: 13 | render: bash 14 | validations: 15 | required: true 16 | - type: dropdown 17 | id: assignee 18 | attributes: 19 | label: Do you want to work on this issue? 20 | multiple: false 21 | options: 22 | - "No" 23 | - "Yes" 24 | default: 0 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "githubPullRequests.ignoredPullRequestBranches": [ 3 | "main" 4 | ] 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | Logo 7 | 8 |

TFT git python rewrite

9 | 10 |

11 | Best way to learn git....Write your own 12 |
13 | Explore the docs » 14 |
15 |
16 |

17 |
18 | 19 | 20 | 21 | 22 |
23 | Table of Contents 24 |
    25 |
  1. 26 | About The Project 27 | 30 |
  2. 31 |
  3. 32 | Getting Started 33 | 37 |
  4. 38 |
  5. Usage
  6. 39 |
  7. License
  8. 40 |
  9. Contact
  10. 41 |
  11. Acknowledgments
  12. 42 |
43 |
44 | 45 | 46 | 47 | 48 | ## About The Project 49 |
50 | Video tutorial 51 | Project image 52 |
53 | Just rewriting Git in python. Why? 54 | 55 | Here's why: 56 | * Is fun 57 | * To finally get it 58 | 59 | ## Classes 60 | 61 | 62 |

(back to top)

63 | 64 | ### GitRepository 65 | 1. **Description:** the repository object 66 | 2. **Attributes:** 67 | - worktree: the work tree is the path where the files that are meant to be in version control are 68 | - gitdir: the git directory is the path where git stores its own data. Usually is a child directory of the work tree, called .git 69 | - conf: is an instance of the class ConfigParser, from the external module configparser, used to read and write INI configuration files 70 | 71 | ### GitObject 72 | 1. **Description:** base class that abstracts the common features of different object types (e.g., blob, commit, tag or tree) 73 | 2. **Methods:** 74 | - init: will be used by the derived class to create a new empty object if needed (optional) 75 | - deserialize: will be used by the derived class to convert the data into an object (mandatory) 76 | - serialize: will be used by the derived class to convert the object into a meaningful representation (mandatory) 77 | 78 |

(back to top)

79 | 80 | 81 | 82 | ### Built With 83 | 84 | * [![Python][Python]][Python-url] 85 | 86 |

(back to top)

87 | 88 | 89 | ## Getting Started 90 | 91 | 92 | ### Prerequisites 93 | 94 | * Python version 3.10 or higher 95 | 96 | ### Installation 97 | 98 | 99 |

(back to top)

100 | 101 | 102 | 103 | 104 | ## Usage 105 | 106 | Use this space to show useful examples of how a project can be used. Additional screenshots, code examples and demos work well in this space. You may also link to more resources. 107 | 108 | ### Init Command 109 | To initialize a new empty TFT repository, use the following command: 110 | ```bash 111 | tft init [path] 112 | ``` 113 | where [path] is the optional path where the repository will be created. If not provided, the repository will be created in the current directory. 114 | 115 | _For more examples, please refer to the [Documentation](https://wyag.thb.lt/)_ 116 | 117 |

(back to top)

118 | 119 | 120 | 121 | 122 | 123 | ## License 124 | 125 | 126 | 127 |

(back to top)

128 | 129 | 130 | 131 | 132 | ## Contact 133 | 134 | 135 | 136 |

(back to top)

137 | 138 | 139 | 140 | 141 | ## Acknowledgments 142 | 143 | A few of helpful link 144 | 145 | * [Choose an Open Source License](https://choosealicense.com) 146 | * [Python](https://www.python.org/) 147 | * [GIT](https://git-scm.com/doc) 148 | * [WYAG](https://wyag.thb.lt/) 149 | 150 | 151 | 152 |

(back to top)

153 | 154 | 155 | 156 | 157 | 158 | [Python]: https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54 159 | [Python-url]: https://www.python.org/ 160 | 161 | -------------------------------------------------------------------------------- /libtft.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import collections 3 | import configparser 4 | from datetime import datetime 5 | import grp, pwd 6 | from fnmatch import fnmatch 7 | import hashlib 8 | from math import ceil 9 | import os 10 | import re 11 | import sys 12 | import zlib 13 | from pathlib import Path 14 | 15 | argparser = argparse.ArgumentParser(description="The stupidest version control") 16 | argsubparsers = argparser.add_subparsers(title="Commands", dest="command") 17 | argsubparsers.required = True 18 | 19 | #subparser for init 20 | argsp = argsubparsers.add_parser("init", help="Initialize a new empty tft repository.") 21 | argsp.add_argument("path", metavar="directory", nargs="?", default=".", help="Where to create the repository.") 22 | 23 | #subparser for hash-object 24 | argsp = argsubparsers.add_parser("hash-object", help="Compute object ID and optionally creates a blob from a file") 25 | argsp.add_argument("-t", metavar="type", dest="type", choices=["blob", "commit", "tag", "tree"], default="blob", help="Specify the type") 26 | argsp.add_argument("-w", dest="write", action="store_true", help="Actually write the object into the database") 27 | argsp.add_argument("path", help="Read object from ") 28 | 29 | #subparser for status 30 | argsp = argsubparsers.add_parser("status", help = "Show the working tree status.") 31 | 32 | #subparser for ls-files 33 | argsp = argsubparsers.add_parser("ls-files", help = "List all the stage files") 34 | argsp.add_argument("--verbose", action="store_true", help="Show everything.") 35 | 36 | #subparser for rev-parse 37 | argsp = argsubparsers.add_parser("rev-parse", help="Parse revision (or other objects) identifiers") 38 | argsp.add_argument("--wyag-type", metavar="type", dest="type", 39 | choices=["blob", "commit", "tag", "tree"], 40 | default=None, help="Specify the expected type") 41 | argsp.add_argument("name", help="The name to parse") 42 | 43 | #subparser for tag 44 | argsp = argsubparsers.add_parser("tag",help="List and create tags") 45 | argsp.add_argument("-a",action="store_true",dest="create_tag_object",help="Whether to create a tag object") 46 | argsp.add_argument("name",nargs="?",help="The new tag's name") 47 | argsp.add_argument("object",default="HEAD",nargs="?",help="The object the new tag will point to") 48 | 49 | #subparser for show-ref 50 | argsp = argsubparsers.add_parser("show-ref", help="List references in the current repository.") 51 | 52 | #subparser for ls-tree 53 | argsp = argsubparsers.add_parser("ls-tree", help="Pretty-print a tree object.") 54 | argsp.add_argument("-r", dest="recursive", action="store_true", help="Recurse into sub-trees") 55 | argsp.add_argument("tree", help="A tree-ish object.") 56 | 57 | #subparser for log command 58 | argsp = argsubparsers.add_parser("log", help="Display history of a given commit.") 59 | argsp.add_argument("commit", 60 | default="HEAD", 61 | nargs="?", 62 | help="Commit to start at.") 63 | 64 | #subparser for check-ignore command 65 | argsp = argsubparsers.add_parser("check-ignore", help = "Check path(s) against ignore rules.") 66 | argsp.add_argument("path", nargs="+", help="Paths to check") 67 | 68 | def main(argv=sys.argv[1:]): 69 | args = argparser.parse_args(argv) 70 | match args.command: 71 | case "add" : cmd_add(args) 72 | case "cat-file" : cmd_cat_file(args) 73 | case "check-ignore" : cmd_check_ignore(args) 74 | case "checkout" : cmd_checkout(args) 75 | case "commit" : cmd_commit(args) 76 | case "hash-object" : cmd_hash_object(args) 77 | case "init" : cmd_init(args) 78 | case "log" : cmd_log(args) 79 | case "ls-files" : cmd_ls_files(args) 80 | case "ls-tree" : cmd_ls_tree(args) 81 | case "rev-parse" : cmd_rev_parse(args) 82 | case "rm" : cmd_rm(args) 83 | case "show-ref" : cmd_show_ref(args) 84 | case "status" : cmd_status(args) 85 | case "tag" : cmd_tag(args) 86 | case _ : print("Bad command.")\ 87 | 88 | class GitRepository(object): 89 | worktree = None 90 | gitdir = None 91 | conf = None 92 | 93 | def __init__(self, path, force=False): 94 | self.worktree = path 95 | self.gitdir = os.path.join(path, ".git") 96 | 97 | if not (force or os.path.isdir(self.gitdir)): 98 | raise Exception("Not a Git repository %s" % path) 99 | 100 | # Read configuration file in .git/config 101 | self.conf = configparser.ConfigParser() 102 | cf = repo_file(self, "config") 103 | 104 | if cf and os.path.exists(cf): 105 | self.conf.read([cf]) 106 | elif not force: 107 | raise Exception("Configuration file missing") 108 | 109 | if not force: 110 | vers = int(self.conf.get("core", "repositoryformatversion")) 111 | if vers != 0: 112 | raise Exception("Unsupported repositoryformatversion %s" % vers) 113 | 114 | 115 | class GitObject (object): 116 | 117 | def __init__(self, data=None): 118 | if data != None: 119 | self.deserialize(data) 120 | else: 121 | self.init() 122 | 123 | def serialize(self, repo): 124 | raise Exception("Unimplemented!") 125 | 126 | def deserialize(self, data): 127 | raise Exception("Unimplemented!") 128 | 129 | def init(self): 130 | pass 131 | 132 | 133 | class GitCommit(GitObject): 134 | # Specify the object format as 'commit' 135 | fmt = b'commit' 136 | 137 | def __init__(self): 138 | # Initialize the commit object with an empty key-value list map (KVL) 139 | self.kvlm = {} 140 | 141 | def read_data(self, data): 142 | """Deserialize the data into a key-value list map (KVL).""" 143 | self.kvlm = kvlm_parse(data) 144 | 145 | def write_data(self): 146 | """Serialize the commit's key-value list map (KVL) back into bytes.""" 147 | return kvlm_serialize(self.kvlm) 148 | 149 | 150 | class GitBlob(GitObject): 151 | # Blob format type 152 | fmt = b'blob' 153 | 154 | def serialize(self): 155 | """Returns the blob data.""" 156 | return self.blobdata 157 | 158 | def deserialize(self, data): 159 | """Stores the data in the blob.""" 160 | self.blobdata = data 161 | 162 | class GitTag(GitCommit): 163 | fmt = b'tag' 164 | 165 | class GitIndexEntry(object): 166 | def __init__(self, ctime=None, mtime=None, dev=None, ino=None, 167 | mode_type=None, mode_perms=None, uid=None, gid=None, 168 | fsize=None, sha=None, flag_assume_valid=None, 169 | flag_stage=None, name=None): 170 | # Last modification of metadata 171 | self.ctime = ctime 172 | # Last modification of data 173 | self.mtime = mtime 174 | # Device ID 175 | self.dev = dev 176 | # The file's inode number 177 | self.ino = ino 178 | # File mode 179 | self.mode_type = mode_type 180 | # Permissions as integer 181 | self.mode_perms = mode_perms 182 | # Owner user ID 183 | self.uid = uid 184 | # Owner group ID 185 | self.gid = gid 186 | # Size of the file 187 | self.fsize = fsize 188 | # SHA-1 of the file 189 | self.sha = sha 190 | # The file is assumed to be valid 191 | self.flag_assume_valid = flag_assume_valid 192 | # The file is staged 193 | self.flag_stage = flag_stage 194 | # The file name 195 | self.name = name 196 | 197 | class GitIndex(object): 198 | version = None 199 | entries = [] 200 | def __init__(self, version=2, entries=list()): 201 | self.version = version 202 | self.entries = entries 203 | 204 | class GitTree(GitObject): 205 | fmt = b'tree' 206 | def serialize(self): 207 | return tree_serialize(self) 208 | 209 | def deserialize(self, data): 210 | self.items = tree_parse(data) 211 | 212 | def init(self): 213 | self.items = list() 214 | 215 | class GitTreeLeaf(object): 216 | def __init__(self, mode, path, sha): 217 | self.mode = mode 218 | self.path = path 219 | self.sha = sha 220 | 221 | def tree_parse_one(raw, start=0): 222 | # Find the first space 223 | x = raw.find(b' ', start) 224 | assert x - start == 5 or x - start == 6 225 | # Read the mode 226 | mode = raw[start:x] 227 | if len(mode): 228 | mode = b' ' + mode 229 | # Find the NULL value 230 | y = raw.find(b'\x00', x) 231 | # Read the path 232 | path = raw[x + 1:y] 233 | 234 | # Read the sha 235 | sha = format(int.from_bytes(raw[y + 1:], 'big'), '040x') 236 | return y + 21, GitTreeLeaf(mode, path.decode('utf-8'), sha) 237 | 238 | def tree_parse(raw): 239 | pos = 0 240 | max = len(raw) 241 | ret = list() 242 | while pos < max: 243 | pos, leaf = tree_parse_one(raw, pos) 244 | ret.append(leaf) 245 | 246 | return ret 247 | 248 | def tree_serialize(obj): 249 | obj.items.sort(key=tree_leaf_sort_key) 250 | ret = b'' 251 | for i in obj.items: 252 | ret += i.mode 253 | ret += b' ' 254 | ret += i.path.encode('utf-8') 255 | ret += b'\x00' 256 | sha = int(i.sha, 16) 257 | ret += sha.to_bytes(20, 'big') 258 | 259 | return ret 260 | 261 | def tree_leaf_sort_key(leaf): 262 | return leaf.path + ('' if leaf.path.startswith(b'10') else '/') 263 | 264 | def repo_path(repo, *path): 265 | """Compute path under repo's gitdir.""" 266 | return os.path.join(repo.gitdir, *path) 267 | 268 | def repo_file(repo, *path, mkdir=False): 269 | """Same as repo_path, but create dirname(*path) if absent. For 270 | example, repo_file(r, \"refs\", \"remotes\", \"origin\", \"HEAD\") will create 271 | .git/refs/remotes/origin.""" 272 | 273 | if repo_dir(repo, *path[:-1], mkdir=mkdir): 274 | return repo_path(repo, *path) 275 | 276 | def repo_dir(repo, *path, mkdir=False): 277 | """Same as repo_path, but mkdir *path if absent if mkdir.""" 278 | 279 | path = repo_path(repo, *path) 280 | 281 | if os.path.exists(path): 282 | if (os.path.isdir(path)): 283 | return path 284 | else: 285 | raise Exception("Not a directory %s" % path) 286 | 287 | if mkdir: 288 | os.makedirs(path) 289 | return path 290 | else: 291 | return None 292 | 293 | 294 | def repo_default_config(): 295 | ret = configparser.ConfigParser() 296 | 297 | ret.add_section("core") 298 | ret.set("core", "repositoryformatversion", "0") 299 | ret.set("core", "filemode", "false") 300 | ret.set("core", "bare", "false") 301 | 302 | return ret 303 | 304 | 305 | def repo_create(path): 306 | """Create a new repository at path.""" 307 | 308 | repo = GitRepository(path, True) 309 | 310 | # First, we make sure the path either doesn't exist or is an 311 | # empty dir. 312 | 313 | if os.path.exists(repo.worktree): 314 | if not os.path.isdir(repo.worktree): 315 | raise Exception ("%s is not a directory!" % path) 316 | if os.path.exists(repo.gitdir) and os.listdir(repo.gitdir): 317 | raise Exception("%s is not empty!" % path) 318 | else: 319 | os.makedirs(repo.worktree) 320 | 321 | assert repo_dir(repo, "branches", mkdir=True) 322 | assert repo_dir(repo, "objects", mkdir=True) 323 | assert repo_dir(repo, "refs", "tags", mkdir=True) 324 | assert repo_dir(repo, "refs", "heads", mkdir=True) 325 | 326 | # .git/description 327 | with open(repo_file(repo, "description"), "w") as f: 328 | f.write("Unnamed repository; edit this file 'description' to name the repository.\n") 329 | 330 | # .git/HEAD 331 | with open(repo_file(repo, "HEAD"), "w") as f: 332 | f.write("ref: refs/heads/master\n") 333 | 334 | with open(repo_file(repo, "config"), "w") as f: 335 | config = repo_default_config() 336 | config.write(f) 337 | 338 | return repo 339 | 340 | def repo_find(path=".", required=True): 341 | """" 342 | Finds the root of the current repository. 343 | """ 344 | #gets the real path resolving symlinks 345 | path = Path(path).resolve() 346 | 347 | #check if the path contains the .git directory 348 | path_to_check = path.joinpath(".git") 349 | if path_to_check.is_dir(): 350 | return GitObject(path) 351 | 352 | #if it doesn't try to get the parent directory of path 353 | parent = path.joinpath("..").resolve() 354 | 355 | #if parent directory corresponds to the path it means we've reached the base directory. Git repository isn't found 356 | if parent == path: 357 | if required: 358 | raise Exception("No tft Repository found.") 359 | else: 360 | return None 361 | 362 | #otherwise we'll do this again with the parent directory 363 | return repo_find(parent, required) 364 | 365 | 366 | def object_read(repo, sha): 367 | 368 | #read file .git/objects where first two are the directory name, the rest as the file name 369 | path = repo_file(repo, "objects", sha[0:2], sha[2:]) 370 | 371 | if not os.path.isfile(path): 372 | return None 373 | 374 | with open (path, "rb") as f: 375 | raw = zlib.decompress(f.read()) 376 | 377 | # Read object type "commit", "tree", "blob", "tag" 378 | x = raw.find(b' ') 379 | fmt = raw[0:x] 380 | 381 | # Read and validate object size 382 | y = raw.find(b'\x00', x) 383 | size = int(raw[x:y].decode("ascii")) 384 | if size != len(raw)-y-1: 385 | raise Exception("Malformed object {0}: bad length".format(sha)) 386 | 387 | # Pick the correct constructor depending on the type read above 388 | match fmt: 389 | case b'commit' : c=GitCommit 390 | case b'tree' : c=GitTree 391 | case b'tag' : c=GitTag 392 | case b'blob' : c=GitBlob 393 | case _: 394 | raise Exception("Unknown type {0} for object {1}".format(fmt.decode("ascii"), sha)) 395 | 396 | # Construct and return an instance of the corresponding Git object type 397 | return c(raw[y+1:]) 398 | 399 | def object_hash(fd, fmt, repo=None): 400 | """Hash object, writing it to repo if provided.""" 401 | data = fd.read() 402 | 403 | # Choose constructor according to fmt argument 404 | match fmt: 405 | case b'commit' : obj=GitCommit(data) 406 | case b'tree' : obj=GitTree(data) 407 | case b'tag' : obj=GitTag(data) 408 | case b'blob' : obj=GitBlob(data) 409 | case _: raise Exception("Unknown type %s!" % fmt) 410 | 411 | return object_write(obj, repo) 412 | 413 | def object_write(obj, repo=None): 414 | # Serialize object data 415 | data = obj.serialize() 416 | # Add header to serialized data 417 | result = obj.fmt + b' ' + str(len(data)).encode() + b'\x00' + data 418 | # Compute hash 419 | sha = hashlib.sha1(result).hexdigest() 420 | 421 | if repo: 422 | # Compute path 423 | path=repo_file(repo, "objects", sha[0:2], sha[2:], mkdir=True) 424 | 425 | #Extra check before writing 426 | if not os.path.exists(path): 427 | with open(path, 'wb') as f: 428 | # Compress and write 429 | f.write(zlib.compress(result)) 430 | return sha 431 | 432 | def object_find(repo, name, fmt=None, follow=True): 433 | """Just temporary, will implement this fully soon""" 434 | return name 435 | 436 | 437 | def cmd_ls_files(args): 438 | repo = repo_find() 439 | index = index_read(repo) 440 | 441 | if args.verbose: 442 | print("Index file format v{}, containing {} entries.".format(index.version, len(index.entries))) 443 | 444 | for entry in index.entries: 445 | print(entry.name) 446 | if args.verbose: 447 | print(" {} with perms: {:o}".format( 448 | {0b1000: "regular file", 449 | 0b1010: "symlink", 450 | 0b1110: "git link"}[entry.mode_type], 451 | entry.mode_perms)) 452 | print(" on blob: {}".format(entry.sha)) 453 | print(" created: {}.{}, modified: {}.{}".format( 454 | datetime.fromtimestamp(entry.ctime[0]), 455 | entry.ctime[1], 456 | datetime.fromtimestamp(entry.mtime[0]), 457 | entry.mtime[1])) 458 | print(" device: {}, inode: {}".format(entry.dev, entry.ino)) 459 | print(" user: {} ({}) group: {} ({})".format( 460 | pwd.getpwuid(entry.uid).pw_name, 461 | entry.uid, 462 | grp.getgrgid(entry.gid).gr_name, 463 | entry.gid)) 464 | print(" flags: stage={} assume_valid={}".format( 465 | entry.flag_stage, 466 | entry.flag_assume_valid)) 467 | 468 | sha = object_resolve(repo, name) 469 | 470 | if not sha: 471 | raise Exception("No such reference {0}.".format(name)) 472 | 473 | if len(sha) > 1: 474 | raise Exception("Ambiguous reference {0}: Candidates are:\n - {1}.".format(name, "\n - ".join(sha))) 475 | 476 | sha = sha[0] 477 | 478 | if not fmt: 479 | return sha 480 | 481 | while True: 482 | obj = object_read(repo, sha) 483 | if obj.fmt == fmt: 484 | return sha 485 | if not follow: 486 | return None 487 | sha = obj.oid 488 | 489 | if obj.fmt == b'tag': 490 | sha = obj.kvlm[b'object'].decode("ascii") 491 | elif obj.fmt == b'commit' and fmt == b'tree': 492 | sha = obj.kvlm[b'tree'].decode("ascii") 493 | else: 494 | return None 495 | 496 | def index_read(repo): 497 | index_file = repo_file(repo, "index") 498 | 499 | if not os.path.exists(index_file): 500 | return GitIndex() 501 | 502 | with open(index_file, "rb") as f: 503 | raw = f.read() 504 | 505 | # First 12 bytes are the header 506 | header = raw[:12] 507 | signature = header[:4] 508 | assert signature == b"DIRC" # DirCache 509 | version = int.from_bytes(header[4:8], 'big') 510 | # Tft only supports index file version 2 511 | assert version == 2 512 | count = int.from_bytes(header[8:12], "big") 513 | 514 | entries = list() 515 | 516 | content = raw[12:] 517 | index = 0 518 | for i in range(count): 519 | 520 | ctime_s = int.from_bytes(content[idx: idx+4], "big") 521 | ctime_ns = int.from_bytes(content[idx+4: idx+8], "big") 522 | mtime_s = int.from_bytes(content[idx+8: idx+12], "big") 523 | mtime_ns = int.from_bytes(content[idx+12: idx+16], "big") 524 | dev = int.from_bytes(content[idx+16: idx+20], "big") 525 | ino = int.from_bytes(content[idx+20: idx+24], "big") 526 | unused = int.from_bytes(content[idx+24: idx+26], "big") 527 | assert 0 == unused 528 | mode = int.from_bytes(content[idx+26: idx+28], "big") 529 | mode_type = mode >> 12 530 | assert mode_type in [0b1000, 0b1010, 0b1110] 531 | mode_perms = mode & 0b0000000111111111 532 | uid = int.from_bytes(content[idx+28: idx+32], "big") 533 | gid = int.from_bytes(content[idx+32: idx+36], "big") 534 | fsize = int.from_bytes(content[idx+36: idx+40], "big") 535 | sha = format(int.from_bytes(content[idx+40: idx+60], "big"), "040x") 536 | flags = int.from_bytes(content[idx+60: idx+62], "big") 537 | flag_assume_valid = (flags & 0b1000000000000000) != 0 538 | flag_extended = (flags & 0b0100000000000000) != 0 539 | assert not flag_extended 540 | flag_stage = flags & 0b0011000000000000 541 | name_length = flags & 0b0000111111111111 542 | 543 | idx += 62 544 | 545 | if name_length < 0xFFF: 546 | assert content[idx + name_length] == 0x00 547 | raw_name = content[idx:idx+name_length] 548 | idx += name_length + 1 549 | else: 550 | print("Notice: Name is 0x{:X} bytes long.".format(name_length)) 551 | null_idx = content.find(b'\x00', idx + 0xFFF) 552 | raw_name = content[idx: null_idx] 553 | idx = null_idx + 1 554 | 555 | # Just parse the name as utf8. 556 | name = raw_name.decode("utf8") 557 | 558 | idx = 8 * ceil(idx / 8) 559 | 560 | # And we add this entry to our list. 561 | entries.append(GitIndexEntry(ctime=(ctime_s, ctime_ns), 562 | mtime=(mtime_s, mtime_ns), 563 | dev=dev, 564 | ino=ino, 565 | mode_type=mode_type, 566 | mode_perms=mode_perms, 567 | uid=uid, 568 | gid=gid, 569 | fsize=fsize, 570 | sha=sha, 571 | flag_assume_valid=flag_assume_valid, 572 | flag_stage=flag_stage, 573 | name=name)) 574 | 575 | return GitIndex(version=version, entries=entries) 576 | 577 | def object_resolve(repo, name): 578 | candidates = list() 579 | hashRe = re.compile(b'^[0-9A-Fa-f]{4,40}$') # Hex string matcher 580 | 581 | if not name.strip(): # Empty string 582 | return None 583 | 584 | if name == "HEAD": # HEAD case 585 | return [ ref_resolve(repo, "HEAD") ] 586 | 587 | if hashRe.match(name):# Short or long hash 588 | name = name.lower() 589 | prefix = name[0:2] 590 | path = repo_dir(repo, "objects", prefix) 591 | 592 | if path: 593 | rem = name[2:] 594 | for f in os.listdir(path): 595 | if f.startswith(rem): 596 | candidates.append(prefix + f) 597 | 598 | as_tag = ref_resolve(repo, "refs/tags/" + name) 599 | if as_tag: # Ref case 600 | candidates.append(as_tag) 601 | 602 | as_branch = ref_resolve(repo, "refs/heads/" + name) 603 | if as_branch: # Branch case 604 | candidates.append(as_branch) 605 | return candidates 606 | 607 | def kvlm_parse(raw, start=0, dct=None): 608 | # dct initialization 609 | if not dct: 610 | dct = collections.OrderedDict() 611 | 612 | # Find the next space and the next newline 613 | spc = raw.find(b' ', start) 614 | nl = raw.find(b'\n', start) 615 | 616 | # BASE CASE : newline appears before a space or there is no space 617 | if (spc < 0) or (nl < spc): 618 | assert nl == start 619 | dct[None] = raw[start+1:] 620 | return dct 621 | 622 | # RECURSIVE CASE : we read a key-value pair and then recurse for the next 623 | key = raw[start:spc] 624 | 625 | # Find the end of the value 626 | end = start 627 | while True: 628 | end = raw.find(b'\n', end+1) 629 | if raw[end+1] != ord(' '): 630 | break 631 | 632 | # Grab the value and drop the leading space on continuation lines 633 | value = raw[spc+1:end].replace(b'\n ', b'\n') 634 | 635 | # Don't overwrite existing data contents 636 | if key in dct: 637 | if type(dct[key]) == list: 638 | dct[key].append(value) 639 | else: 640 | dct[key] = [ dct[key], value ] 641 | else: 642 | dct[key]=value 643 | 644 | # Recursive call to parse the rest of the data 645 | return kvlm_parse(raw, start=end+1, dct=dct) 646 | 647 | def ref_resolve(repo, ref): 648 | path = repo_file(repo, ref) 649 | 650 | if not os.path.isfile(path): 651 | return None 652 | 653 | with open(path, 'r') as fp: 654 | data = fp.read()[:-1] 655 | 656 | if data.startswith("ref: "): 657 | return ref_resolve(repo, data[5:]) 658 | else: 659 | return data 660 | 661 | def ref_list(repo, path=None): 662 | if not path: 663 | path = repo_dir(repo, "refs") 664 | ret = collections.OrderedDict() 665 | 666 | for f in sorted(os.listdir(path)): 667 | can = os.path.join(path, f) 668 | if os.path.isdir(can): 669 | ret[f] = ref_list(repo, can) 670 | else: 671 | ret[f] = ref_resolve(repo, can) 672 | 673 | return ret 674 | 675 | def show_ref(repo, refs, with_hash=True, prefix=''): 676 | for name, val in refs.items(): 677 | if type(val) == str: 678 | print("{0}{1}{2}".format( 679 | val + " " if with_hash else "", 680 | prefix + "/" if prefix else "", 681 | name)) 682 | else: 683 | show_ref(repo, val, with_hash, prefix=f"{prefix}{"/" if prefix else ""}{name}") 684 | 685 | def kvlm_serialize(kvlm): 686 | res = b'' 687 | 688 | # Output fields 689 | for key in kvlm.keys(): 690 | # Skip the message itself 691 | if key == None: continue 692 | 693 | val = kvlm[key] 694 | # Normalize to a list 695 | if type(val) != list: 696 | val = [ val ] 697 | 698 | # Serialize each value 699 | for v in val: 700 | res += key + b' ' + (v.replace(b'\n', b'\n ')) + b'\n' 701 | 702 | # Append message 703 | res += b'\n' + kvlm[None] + b'\n' 704 | 705 | return res 706 | 707 | def branch_get_active(repo): 708 | with open(repo_file(repo, "HEAD"), "rb") as f: 709 | head = f.read() 710 | 711 | if head.startswith(b'ref: refs/heads/'): 712 | return head[16:-1] 713 | return False 714 | 715 | def tree_to_dict(repo, ref, prefix=""): 716 | ret = dict() 717 | tree_sha = object_find(repo, ref, fmt=b'tree') 718 | tree = object_read(repo, tree_sha) 719 | 720 | for leaf in tree.items: 721 | full_path = os.path.join(prefix, leaf.path) 722 | is_subtree = leaf.mode.startswith('04') 723 | if is_subtree: 724 | ret.update(tree_to_dict(repo, leaf.sha, full_path)) 725 | else: 726 | ret[full_path] = leaf.sha 727 | return ret 728 | 729 | def ls_tree(repo, ref, recursive=None, prefix=''): 730 | obj = object_read(repo, object_find(repo, ref, fmt=b'tree')) 731 | for item in obj.items: 732 | type = item.mode[0:(1 if len(item.mode) == 5 else 2)] 733 | match (type): 734 | case '04': type = 'tree' 735 | case '10': type = 'blob' 736 | case '12': type = 'blob' 737 | case '16': type = 'blob' 738 | case _: raise Exception("Unknown type %s!" % type) 739 | if not (recursive and type == 'tree'): 740 | print("{0} {1} {2}\t{3}".format( 741 | "0" * (6 - len(item.mode)) + item.mode.decode("ascii"), type, 742 | item.sha, 743 | os.path.join(prefix, item.path))) 744 | else: 745 | ls_tree(repo, item.sha, recursive, prefix=os.path.join(prefix, item.path)) 746 | 747 | def cat_file(repo, obj, fmt=None): 748 | obj = object_read(repo, object_find(repo, obj, fmt=fmt)) 749 | sys.stdout.buffer.write(obj.serialize()) 750 | 751 | #Bride functions 752 | def cmd_init(args): 753 | """Bridge function to initialize a new repository.""" 754 | repo_create(args.path) 755 | 756 | def cmd_log(args): 757 | repo = repo_find() 758 | 759 | print("digraph wyaglog{") 760 | print(" node[shape=rect]") 761 | log_graphviz(repo, object_find(repo, args.commit), set()) 762 | print("}") 763 | 764 | def log_graphviz(repo, sha, seen): 765 | 766 | if sha in seen: 767 | return 768 | seen.add(sha) 769 | 770 | commit = object_read(repo, sha) 771 | short_hash = sha[0:8] 772 | message = commit.kvlm[None].decode("utf8").strip() 773 | message = message.replace("\\", "\\\\") 774 | message = message.replace("\"", "\\\"") 775 | 776 | if "\n" in message: # Keep only the first line 777 | message = message[:message.index("\n")] 778 | 779 | print(" c_{0} [label=\"{1}: {2}\"]".format(sha, sha[0:7], message)) 780 | assert commit.fmt==b'commit' 781 | 782 | if not b'parent' in commit.kvlm.keys(): 783 | # Base case: the initial commit. 784 | return 785 | 786 | parents = commit.kvlm[b'parent'] 787 | 788 | if type(parents) != list: 789 | parents = [ parents ] 790 | 791 | for p in parents: 792 | p = p.decode("ascii") 793 | print (" c_{0} -> c_{1};".format(sha, p)) 794 | log_graphviz(repo, p, seen) 795 | 796 | def cmd_hash_object(args): 797 | """Bridge function to compute the hash-name of object and optionally create the blob""" 798 | if args.write: 799 | repo = repo_find() 800 | else: 801 | repo = None 802 | 803 | with open(args.path, "rb") as fd: 804 | sha = object_hash(fd, args.type.encode(), repo) 805 | print(sha) 806 | 807 | def cmd_status(_): 808 | repo = repo_find() 809 | index = index_read(repo) 810 | 811 | cmd_status_branch(repo) 812 | cmd_status_head_index(repo, index) 813 | print() 814 | cmd_status_index_worktree(repo, index) 815 | 816 | def cmd_status_branch(repo): 817 | branch = branch_get_active(repo) 818 | if branch: 819 | print("On branch: {}".format(branch)) 820 | else: 821 | print("HEAD detached at: {}".format(object_find(repo, "HEAD"))) 822 | 823 | def cmd_status_head_index(repo, index): 824 | print("Changes to be committed: ") 825 | 826 | head = tree_to_dict(repo, "HEAD") 827 | for entry in index.entries: 828 | if entry.name in head: 829 | if head[entry.name] != entry.sha: 830 | print(" modified:", entry.name) 831 | # Delete the entry in the index if it exists in HEAD 832 | del head[entry.name] 833 | else: 834 | print(" added:", entry.name) 835 | 836 | # Deleted files 837 | for entry in head: 838 | print(" deleted:", entry) 839 | 840 | def cmd_status_index_worktree(repo, index): 841 | print("Changes not staged for commit: ") 842 | 843 | ignore = gitignore_read(repo) 844 | 845 | gitdir_prefix = repo.gitdir + os.path.sep 846 | 847 | files = list() 848 | 849 | for (root, _, filenames) in os.walk(repo.worktree, True): 850 | if root == repo.gitdir or root.startswith(gitdir_prefix): continue 851 | for f in filenames: 852 | files.append(os.path.relpath(os.path.join(root, f), repo.worktree)) 853 | 854 | for entry in index.entries: 855 | full_path = os.path.join(repo.worktree, entry.name) 856 | 857 | if not os.path.exists(full_path): 858 | print(" deleted:", entry.name) 859 | else: 860 | stat = os.stat(full_path) 861 | 862 | ctime_ns = entry.ctime[0] * 10**9 + entry.ctime[1] 863 | mtime_ns = entry.mtime[0] * 10**9 + entry.mtime[1] 864 | 865 | if stat.st_ctime_ns != ctime_ns or stat.st_mtime_ns != mtime_ns: 866 | with open(full_path, "rb") as f: 867 | sha = object_hash(f, b'blob', None) 868 | 869 | if entry.sha != sha: 870 | print(" modified:", entry.name) 871 | if entry.name in files: 872 | files.remove(entry.name) 873 | 874 | print("\nUntracked files: ") 875 | for f in files: 876 | if check_ignore(ignore, f): continue 877 | print(" ", f) 878 | 879 | #Check-ignore function 880 | def cmd_check_ignore(args): 881 | repo = repo_find() 882 | rules = gitignore_read(repo) 883 | for path in args.path: 884 | if check_ignore(rules, path): 885 | print(path) 886 | 887 | def gitignore_parse1(raw): 888 | raw = raw.strip() #remove space 889 | 890 | if not raw or raw[0] == "#": 891 | return None 892 | elif raw[0] == "!": 893 | return (raw[1:], False) 894 | elif raw[0] == "\\": 895 | return (raw[1:], True) 896 | else: 897 | return (raw, True) 898 | 899 | def gitignore_parse(lines): 900 | ret = list() #rules list 901 | 902 | for line in lines: 903 | parsed = gitignore_parse1(line) 904 | if parsed: 905 | ret.append(parsed) 906 | 907 | return ret 908 | 909 | class GitIgnore(object): 910 | absolute = None 911 | scoped = None 912 | 913 | def __init__(self, absolute, scoped): 914 | self.absolute = absolute 915 | self.scoped = scoped 916 | 917 | def gitignore_read(repo): 918 | ret = GitIgnore(absolute=list(), scoped=dict()) 919 | 920 | #read local configuration: .git/info/exclude 921 | repo_file = os.path.join(repo.gitfir, "info/exclude") 922 | if os.path.exists(repo_file): 923 | with open(repo_file, "r") as f: 924 | ret.absolute.append(gitignore_parse(f.readlines())) 925 | 926 | #global configuration 927 | if "XDG_CONFIG_HOME" in os.environ: 928 | config_home = os.environ["XDG_CONFIG_HOME"] 929 | else: 930 | config_home = os.path.expanduser("~/.config") 931 | global_file = os.path.join(config_home,"git/ignore") 932 | 933 | if os.path.exists(global_file): 934 | with open(global_file, "r") as f: 935 | ret.absolute.append(gitignore_parse(f.readlines())) 936 | 937 | # .gitignore files in the index 938 | index = index_read(repo) 939 | for entry in index.entries: 940 | if entry.name == ".gitignore" or entry.name.endswitch("/.gitignore"): 941 | dir_name = os.path.dirname(entry.name) 942 | contents = object_read(repo, entry.sha) 943 | lines = contents.blobdata.decode("utf8").splitlines() 944 | ret.scoped[dir_name] = gitignore_parse(lines) 945 | return ret 946 | 947 | #function check match with rules 948 | def check_ignore1(rules, path): 949 | result = None # nothing matched 950 | for(pattern, value) in rules: 951 | if fnmatch(path, pattern): 952 | result = value 953 | return result #true or false 954 | 955 | def check_ignore_scoped(rules, path): 956 | #Check ignore rules in parent directories 957 | parent = os.path.dirname(path) 958 | while True: 959 | if parent in rules: 960 | result = check_ignore1(rules[parent], path) 961 | if result != None: 962 | return result 963 | if parent == "": 964 | break 965 | parent = os.path.dirname(parent) 966 | return None 967 | 968 | def check_ignore_absolute(rules, path): 969 | #Check ignore rules in absolute paths 970 | parent = os.path.dirname(path) 971 | for ruleset in rules: 972 | result = check_ignore1(ruleset, path) 973 | if result != None: 974 | return result 975 | return False # This is a reasonable default at this point. 976 | 977 | def check_ignore(rules, path): 978 | #Check if a given path is ignored based on the provided ignore rules 979 | if os.path.isabs(path): 980 | raise Exception("This function requires path to be relative to the repository's root") 981 | 982 | result = check_ignore_scoped(rules.scoped, path) 983 | if result != None: 984 | return result 985 | 986 | return check_ignore_absolute(rules.absolute, path) 987 | 988 | def cmd_rev_parse(args): 989 | """Bridge function to parse a revision.""" 990 | fmt = args.type.encode() if args.type else None 991 | 992 | repo = repo_find() 993 | print(object_find(repo, args.name, fmt, follow=True)) 994 | 995 | def cmd_tag(args): 996 | repo = repo_find() 997 | # If the user provided a name for a new tag 998 | if args.name: 999 | # Call tag_create function to create the new tag in the repository 1000 | tag_create(repo,args.name, args.object, type="object" if args.create_tag_object else "ref") 1001 | # If the user did not provide a name for a new tag 1002 | else: 1003 | # Get the list of references (refs) in the repository 1004 | refs = ref_list(repo) 1005 | # Show the list of tags without their respective hashes 1006 | show_ref(repo, refs["tags"], with_hash=False) 1007 | 1008 | def tag_create(repo, name, ref, create_tag_object=False): 1009 | # get the GitObject from the object reference 1010 | sha = object_find(repo, ref) 1011 | if create_tag_object: 1012 | # create tag object (commit) 1013 | tag = GitTag(repo) 1014 | # Initialize the key-value list map for the tag object 1015 | tag.kvlm = collections.OrderedDict() 1016 | tag.kvlm[b'object'] = sha.encode() 1017 | tag.kvlm[b'type'] = b'commit' 1018 | tag.kvlm[b'tag'] = name.encode() #the user give the name 1019 | tag.kvlm[b'tagger'] = b'Wyag ' 1020 | tag.kvlm[None] = b"A tag generated by tft, which won't let you customize the message!" 1021 | tag_sha = object_write(tag) 1022 | # Create a reference to the tag object in the repository 1023 | ref_create(repo, "tags/" + name, tag_sha) 1024 | else: 1025 | # create lightweight tag (ref) 1026 | ref_create(repo, "tags/" + name, sha) 1027 | 1028 | def ref_create(repo, ref_name, sha): 1029 | with open(repo_file(repo, "refs/" + ref_name), 'w') as fp: 1030 | fp.write(sha + "\n") 1031 | 1032 | def cmd_show_ref(args): 1033 | """Bridge function to show a reference.""" 1034 | repo = repo_find() 1035 | refs = ref_list(repo) 1036 | show_ref(repo, refs, prefix="refs") 1037 | 1038 | def cmd_ls_tree(args): 1039 | """Bridge function to list the contents of a tree object.""" 1040 | repo = repo_find() 1041 | ls_tree(repo, args.tree, args.recursive) 1042 | 1043 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='tft-version-control', 5 | version='0.1', 6 | py_modules=['libtft'], 7 | entry_points={ 8 | 'console_scripts': [ 9 | 'tft = libtft:main', 10 | ], 11 | }, 12 | ) 13 | -------------------------------------------------------------------------------- /tft: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import libtft 4 | libtft.main() --------------------------------------------------------------------------------