├── .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 |
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 | -
26 | About The Project
27 |
30 |
31 | -
32 | Getting Started
33 |
37 |
38 | - Usage
39 | - License
40 | - Contact
41 | - Acknowledgments
42 |
43 |
44 |
45 |
46 |
47 |
48 | ## About The Project
49 |
50 |

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()
--------------------------------------------------------------------------------