Skip to content

Instantly share code, notes, and snippets.

@HUGHNew
Created August 7, 2026 11:23
Show Gist options
  • Select an option

  • Save HUGHNew/3948b35baeb6bd95a9e66a582f1b5188 to your computer and use it in GitHub Desktop.

Select an option

Save HUGHNew/3948b35baeb6bd95a9e66a582f1b5188 to your computer and use it in GitHub Desktop.
Write you own a git with typehint
# This file is part of wyag <https://wyag.thb.lt>
# Copyright (c) 2018-2023 Thibault Polge <thibault@thb.lt>
# All rights reserved
#
# Wyag is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Wyag is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
# License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Wyag. If not, see <https://www.gnu.org/licenses/>.
#
from __future__ import annotations
import argparse
import collections
import configparser
from datetime import datetime
import grp, pwd
from fnmatch import fnmatch
import hashlib
from math import ceil
import os
import re
import sys
import zlib
from typing import Any, BinaryIO
argparser = argparse.ArgumentParser(description="The stupidest content tracker")
argsubparsers = argparser.add_subparsers(title="Commands", dest="command")
argsubparsers.required = True
def main(argv: list[str] | None = sys.argv[1:]) -> None:
args = argparser.parse_args(argv)
match args.command:
case "add" : cmd_add(args)
case "cat-file" : cmd_cat_file(args)
case "check-ignore" : cmd_check_ignore(args)
case "checkout" : cmd_checkout(args)
case "commit" : cmd_commit(args)
case "hash-object" : cmd_hash_object(args)
case "init" : cmd_init(args)
case "log" : cmd_log(args)
case "ls-files" : cmd_ls_files(args)
case "ls-tree" : cmd_ls_tree(args)
case "rev-parse" : cmd_rev_parse(args)
case "rm" : cmd_rm(args)
case "show-ref" : cmd_show_ref(args)
case "status" : cmd_status(args)
case "tag" : cmd_tag(args)
case _ : print("Bad command.")
class GitRepository (object):
"""A git repository"""
worktree: str | None = None
gitdir: str | None = None
conf: configparser.ConfigParser | None = None
def __init__(self, path: str, force: bool = False) -> None:
self.worktree = path
self.gitdir = os.path.join(path, ".git")
if not (force or os.path.isdir(self.gitdir)):
raise Exception("Not a Git repository %s" % path)
# Read configuration file in .git/config
self.conf = configparser.ConfigParser()
cf = repo_file(self, "config")
if cf and os.path.exists(cf):
self.conf.read([cf])
elif not force:
raise Exception("Configuration file missing")
if not force:
vers = int(self.conf.get("core", "repositoryformatversion"))
if vers != 0:
raise Exception("Unsupported repositoryformatversion %s" % vers)
def repo_path(repo: GitRepository, *path: str) -> str:
"""Compute path under repo's gitdir."""
return os.path.join(repo.gitdir, *path)
def repo_file(repo: GitRepository, *path: str, mkdir: bool = False) -> str | None:
"""Same as repo_path, but create dirname(*path) if absent. For
example, repo_file(r, \"refs\", \"remotes\", \"origin\", \"HEAD\") will create
.git/refs/remotes/origin."""
if repo_dir(repo, *path[:-1], mkdir=mkdir):
return repo_path(repo, *path)
def repo_dir(repo: GitRepository, *path: str, mkdir: bool = False) -> str | None:
"""Same as repo_path, but mkdir *path if absent if mkdir."""
path = repo_path(repo, *path)
if os.path.exists(path):
if (os.path.isdir(path)):
return path
else:
raise Exception("Not a directory %s" % path)
if mkdir:
os.makedirs(path)
return path
else:
return None
def repo_create(path: str) -> GitRepository:
"""Create a new repository at path."""
repo = GitRepository(path, True)
# First, we make sure the path either doesn't exist or is an
# empty dir.
if os.path.exists(repo.worktree):
if not os.path.isdir(repo.worktree):
raise Exception ("%s is not a directory!" % path)
if os.path.exists(repo.gitdir) and os.listdir(repo.gitdir):
raise Exception("%s is not empty!" % path)
else:
os.makedirs(repo.worktree)
assert repo_dir(repo, "branches", mkdir=True)
assert repo_dir(repo, "objects", mkdir=True)
assert repo_dir(repo, "refs", "tags", mkdir=True)
assert repo_dir(repo, "refs", "heads", mkdir=True)
# .git/description
with open(repo_file(repo, "description"), "w") as f:
f.write("Unnamed repository; edit this file 'description' to name the repository.\n")
# .git/HEAD
with open(repo_file(repo, "HEAD"), "w") as f:
f.write("ref: refs/heads/master\n")
with open(repo_file(repo, "config"), "w") as f:
config = repo_default_config()
config.write(f)
return repo
def repo_default_config() -> configparser.ConfigParser:
ret = configparser.ConfigParser()
ret.add_section("core")
ret.set("core", "repositoryformatversion", "0")
ret.set("core", "filemode", "false")
ret.set("core", "bare", "false")
return ret
argsp = argsubparsers.add_parser("init", help="Initialize a new, empty repository.")
argsp.add_argument("path",
metavar="directory",
nargs="?",
default=".",
help="Where to create the repository.")
def cmd_init(args: argparse.Namespace) -> None:
repo_create(args.path)
def repo_find(path: str = ".", required: bool = True) -> GitRepository | None:
path = os.path.realpath(path)
if os.path.isdir(os.path.join(path, ".git")):
return GitRepository(path)
# If we haven't returned, recurse in parent, if w
parent = os.path.realpath(os.path.join(path, ".."))
if parent == path:
# Bottom case
# os.path.join("/", "..") == "/":
# If parent==path, then path is root.
if required:
raise Exception("No git directory.")
else:
return None
# Recursive case
return repo_find(parent, required)
class GitObject (object):
fmt: bytes
def __init__(self, data: bytes | None = None) -> None:
if data != None:
self.deserialize(data)
else:
self.init()
def serialize(self, repo: GitRepository) -> bytes:
"""This function MUST be implemented by subclasses.
It must read the object's contents from self.data, a byte string, and do
whatever it takes to convert it into a meaningful representation. What exactly that means depend on each subclass."""
raise Exception("Unimplemented!")
def deserialize(self, data: bytes) -> None:
raise Exception("Unimplemented!")
def init(self) -> None:
pass # Just do nothing. This is a reasonable default!
def object_read(repo: GitRepository, sha: str) -> GitObject | None:
"""Read object sha from Git repository repo. Return a
GitObject whose exact type depends on the object."""
path = repo_file(repo, "objects", sha[0:2], sha[2:])
if not os.path.isfile(path):
return None
with open (path, "rb") as f:
raw = zlib.decompress(f.read())
# Read object type
x = raw.find(b' ')
fmt = raw[0:x]
# Read and validate object size
y = raw.find(b'\x00', x)
size = int(raw[x:y].decode("ascii"))
if size != len(raw)-y-1:
raise Exception("Malformed object {0}: bad length".format(sha))
# Pick constructor
match fmt:
case b'commit' : c=GitCommit
case b'tree' : c=GitTree
case b'tag' : c=GitTag
case b'blob' : c=GitBlob
case _:
raise Exception("Unknown type {0} for object {1}".format(fmt.decode("ascii"), sha))
# Call constructor and return object
return c(raw[y+1:])
def object_write(obj: GitObject, repo: GitRepository | None = None) -> str:
# Serialize object data
data = obj.serialize()
# Add header
result = obj.fmt + b' ' + str(len(data)).encode() + b'\x00' + data
# Compute hash
sha = hashlib.sha1(result).hexdigest()
if repo:
# Compute path
path=repo_file(repo, "objects", sha[0:2], sha[2:], mkdir=True)
if not os.path.exists(path):
with open(path, 'wb') as f:
# Compress and write
f.write(zlib.compress(result))
return sha
class GitBlob(GitObject):
fmt=b'blob'
blobdata: bytes
def serialize(self) -> bytes:
return self.blobdata
def deserialize(self, data: bytes) -> None:
self.blobdata = data
argsp = argsubparsers.add_parser("cat-file",
help="Provide content of repository objects")
argsp.add_argument("type",
metavar="type",
choices=["blob", "commit", "tag", "tree"],
help="Specify the type")
argsp.add_argument("object",
metavar="object",
help="The object to display")
def cmd_cat_file(args: argparse.Namespace) -> None:
repo = repo_find()
cat_file(repo, args.object, fmt=args.type.encode())
def cat_file(repo: GitRepository, obj: str, fmt: bytes | None = None) -> None:
obj = object_read(repo, object_find(repo, obj, fmt=fmt))
sys.stdout.buffer.write(obj.serialize())
argsp = argsubparsers.add_parser(
"hash-object",
help="Compute object ID and optionally creates a blob from a file")
argsp.add_argument("-t",
metavar="type",
dest="type",
choices=["blob", "commit", "tag", "tree"],
default="blob",
help="Specify the type")
argsp.add_argument("-w",
dest="write",
action="store_true",
help="Actually write the object into the database")
argsp.add_argument("path",
help="Read object from <file>")
def cmd_hash_object(args: argparse.Namespace) -> None:
if args.write:
repo = repo_find()
else:
repo = None
with open(args.path, "rb") as fd:
sha = object_hash(fd, args.type.encode(), repo)
print(sha)
def object_hash(fd: BinaryIO, fmt: bytes, repo: GitRepository | None = None) -> str:
""" Hash object, writing it to repo if provided."""
data = fd.read()
# Choose constructor according to fmt argument
match fmt:
case b'commit' : obj=GitCommit(data)
case b'tree' : obj=GitTree(data)
case b'tag' : obj=GitTag(data)
case b'blob' : obj=GitBlob(data)
case _: raise Exception("Unknown type %s!" % fmt)
return object_write(obj, repo)
def kvlm_parse(raw: bytes, start: int = 0,
dct: dict[bytes | None, bytes | list[bytes]] | None = None
) -> dict[bytes | None, bytes | list[bytes]]:
if not dct:
dct = collections.OrderedDict()
# You CANNOT declare the argument as dct=OrderedDict() or all
# call to the functions will endlessly grow the same dict.
# This function is recursive: it reads a key/value pair, then call
# itself back with the new position. So we first need to know
# where we are: at a keyword, or already in the messageQ
# We search for the next space and the next newline.
spc = raw.find(b' ', start)
nl = raw.find(b'\n', start)
# If space appears before newline, we have a keyword. Otherwise,
# it's the final message, which we just read to the end of the file.
# Base case
# =========
# If newline appears first (or there's no space at all, in which
# case find returns -1), we assume a blank line. A blank line
# means the remainder of the data is the message. We store it in
# the dictionary, with None as the key, and return.
if (spc < 0) or (nl < spc):
assert nl == start
dct[None] = raw[start+1:]
return dct
# Recursive case
# ==============
# we read a key-value pair and recurse for the next.
key = raw[start:spc]
# Find the end of the value. Continuation lines begin with a
# space, so we loop until we find a "\n" not followed by a space.
end = start
while True:
end = raw.find(b'\n', end+1)
if raw[end+1] != ord(' '): break
# Grab the value
# Also, drop the leading space on continuation lines
value = raw[spc+1:end].replace(b'\n ', b'\n')
# Don't overwrite existing data contents
if key in dct:
if type(dct[key]) == list:
dct[key].append(value)
else:
dct[key] = [ dct[key], value ]
else:
dct[key]=value
return kvlm_parse(raw, start=end+1, dct=dct)
def kvlm_serialize(kvlm: dict[bytes | None, bytes | list[bytes]]) -> bytes:
ret = b''
# Output fields
for k in kvlm.keys():
# Skip the message itself
if k == None: continue
val = kvlm[k]
# Normalize to a list
if type(val) != list:
val = [ val ]
for v in val:
ret += k + b' ' + (v.replace(b'\n', b'\n ')) + b'\n'
# Append message
ret += b'\n' + kvlm[None] + b'\n'
return ret
class GitCommit(GitObject):
fmt=b'commit'
kvlm: dict[bytes | None, bytes | list[bytes]]
def deserialize(self, data: bytes) -> None:
self.kvlm = kvlm_parse(data)
def serialize(self) -> bytes:
return kvlm_serialize(self.kvlm)
def init(self) -> None:
self.kvlm = dict()
argsp = argsubparsers.add_parser("log", help="Display history of a given commit.")
argsp.add_argument("commit",
default="HEAD",
nargs="?",
help="Commit to start at.")
def cmd_log(args: argparse.Namespace) -> None:
repo = repo_find()
print("digraph wyaglog{")
print(" node[shape=rect]")
log_graphviz(repo, object_find(repo, args.commit), set())
print("}")
def log_graphviz(repo: GitRepository, sha: str, seen: set[str]) -> None:
if sha in seen:
return
seen.add(sha)
commit = object_read(repo, sha)
short_hash = sha[0:8]
message = commit.kvlm[None].decode("utf8").strip()
message = message.replace("\\", "\\\\")
message = message.replace("\"", "\\\"")
if "\n" in message: # Keep only the first line
message = message[:message.index("\n")]
print(" c_{0} [label=\"{1}: {2}\"]".format(sha, sha[0:7], message))
assert commit.fmt==b'commit'
if not b'parent' in commit.kvlm.keys():
# Base case: the initial commit.
return
parents = commit.kvlm[b'parent']
if type(parents) != list:
parents = [ parents ]
for p in parents:
p = p.decode("ascii")
print (" c_{0} -> c_{1};".format(sha, p))
log_graphviz(repo, p, seen)
class GitTreeLeaf (object):
mode: bytes
path: str
sha: str
def __init__(self, mode: bytes, path: str, sha: str) -> None:
self.mode = mode
self.path = path
self.sha = sha
def tree_parse_one(raw: bytes, start: int = 0) -> tuple[int, GitTreeLeaf]:
# Find the space terminator of the mode
x = raw.find(b' ', start)
assert x-start == 5 or x-start==6
# Read the mode
mode = raw[start:x]
if len(mode) == 5:
# Normalize to six bytes.
mode = b" " + mode
# Find the NULL terminator of the path
y = raw.find(b'\x00', x)
# and read the path
path = raw[x+1:y]
# Read the SHA and convert to a hex string
sha = format(int.from_bytes(raw[y+1:y+21], "big"), "040x")
return y+21, GitTreeLeaf(mode, path.decode("utf8"), sha)
def tree_parse(raw: bytes) -> list[GitTreeLeaf]:
pos = 0
max = len(raw)
ret: list[GitTreeLeaf] = list()
while pos < max:
pos, data = tree_parse_one(raw, pos)
ret.append(data)
return ret
# Notice this isn't a comparison function, but a conversion function.
# Python's default sort doesn't accept a custom comparison function,
# like in most languages, but a `key` arguments that returns a new
# value, which is compared using the default rules. So we just return
# the leaf name, with an extra / if it's a directory.
def tree_leaf_sort_key(leaf: GitTreeLeaf) -> str:
if leaf.mode.startswith(b"10"):
return leaf.path
else:
return leaf.path + "/"
def tree_serialize(obj: GitTree) -> bytes:
obj.items.sort(key=tree_leaf_sort_key)
ret = b''
for i in obj.items:
ret += i.mode
ret += b' '
ret += i.path.encode("utf8")
ret += b'\x00'
sha = int(i.sha, 16)
ret += sha.to_bytes(20, byteorder="big")
return ret
class GitTree(GitObject):
fmt=b'tree'
items: list[GitTreeLeaf]
def deserialize(self, data: bytes) -> None:
self.items = tree_parse(data)
def serialize(self) -> bytes:
return tree_serialize(self)
def init(self) -> None:
self.items: list[GitTreeLeaf] = list()
argsp = argsubparsers.add_parser("ls-tree", help="Pretty-print a tree object.")
argsp.add_argument("-r",
dest="recursive",
action="store_true",
help="Recurse into sub-trees")
argsp.add_argument("tree",
help="A tree-ish object.")
def cmd_ls_tree(args: argparse.Namespace) -> None:
repo = repo_find()
ls_tree(repo, args.tree, args.recursive)
def ls_tree(repo: GitRepository, ref: str, recursive: bool | None = None,
prefix: str = "") -> None:
sha = object_find(repo, ref, fmt=b"tree")
obj = object_read(repo, sha)
for item in obj.items:
if len(item.mode) == 5:
type = item.mode[0:1]
else:
type = item.mode[0:2]
match type: # Determine the type.
case b'04': type = "tree"
case b'10': type = "blob" # A regular file.
case b'12': type = "blob" # A symlink. Blob contents is link target.
case b'16': type = "commit" # A submodule
case _: raise Exception("Weird tree leaf mode {}".format(item.mode))
if not (recursive and type=='tree'): # This is a leaf
print("{0} {1} {2}\t{3}".format(
"0" * (6 - len(item.mode)) + item.mode.decode("ascii"),
# Git's ls-tree displays the type
# of the object pointed to. We can do that too :)
type,
item.sha,
os.path.join(prefix, item.path)))
else: # This is a branch, recurse
ls_tree(repo, item.sha, recursive, os.path.join(prefix, item.path))
argsp = argsubparsers.add_parser("checkout", help="Checkout a commit inside of a directory.")
argsp.add_argument("commit",
help="The commit or tree to checkout.")
argsp.add_argument("path",
help="The EMPTY directory to checkout on.")
def cmd_checkout(args: argparse.Namespace) -> None:
repo = repo_find()
obj = object_read(repo, object_find(repo, args.commit))
# If the object is a commit, we grab its tree
if obj.fmt == b'commit':
obj = object_read(repo, obj.kvlm[b'tree'].decode("ascii"))
# Verify that path is an empty directory
if os.path.exists(args.path):
if not os.path.isdir(args.path):
raise Exception("Not a directory {0}!".format(args.path))
if os.listdir(args.path):
raise Exception("Not empty {0}!".format(args.path))
else:
os.makedirs(args.path)
tree_checkout(repo, obj, os.path.realpath(args.path))
def tree_checkout(repo: GitRepository, tree: GitTree, path: str) -> None:
for item in tree.items:
obj = object_read(repo, item.sha)
dest = os.path.join(path, item.path)
if obj.fmt == b'tree':
os.mkdir(dest)
tree_checkout(repo, obj, dest)
elif obj.fmt == b'blob':
# @TODO Support symlinks (identified by mode 12****)
with open(dest, 'wb') as f:
f.write(obj.blobdata)
def ref_resolve(repo: GitRepository, ref: str) -> str | None:
path = repo_file(repo, ref)
# Sometimes, an indirect reference may be broken. This is normal
# in one specific case: we're looking for HEAD on a new repository
# with no commits. In that case, .git/HEAD points to "ref:
# refs/heads/main", but .git/refs/heads/main doesn't exist yet
# (since there's no commit for it to refer to).
if not os.path.isfile(path):
return None
with open(path, 'r') as fp:
data = fp.read()[:-1]
# Drop final \n ^^^^^
if data.startswith("ref: "):
return ref_resolve(repo, data[5:])
else:
return data
def ref_list(repo: GitRepository, path: str | None = None) -> dict[str, Any]:
if not path:
path = repo_dir(repo, "refs")
ret: dict[str, Any] = collections.OrderedDict()
# Git shows refs sorted. To do the same, we use
# an OrderedDict and sort the output of listdir
for f in sorted(os.listdir(path)):
can = os.path.join(path, f)
if os.path.isdir(can):
ret[f] = ref_list(repo, can)
else:
ret[f] = ref_resolve(repo, can)
return ret
argsp = argsubparsers.add_parser("show-ref", help="List references.")
def cmd_show_ref(args: argparse.Namespace) -> None:
repo = repo_find()
refs = ref_list(repo)
show_ref(repo, refs, prefix="refs")
def show_ref(repo: GitRepository, refs: dict[str, Any], with_hash: bool = True,
prefix: str = "") -> None:
for k, v in refs.items():
if type(v) == str:
print ("{0}{1}{2}".format(
v + " " if with_hash else "",
prefix + "/" if prefix else "",
k))
else:
show_ref(repo, v, with_hash=with_hash, prefix="{0}{1}{2}".format(prefix, "/" if prefix else "", k))
class GitTag(GitCommit):
fmt = b'tag'
argsp = argsubparsers.add_parser(
"tag",
help="List and create tags")
argsp.add_argument("-a",
action="store_true",
dest="create_tag_object",
help="Whether to create a tag object")
argsp.add_argument("name",
nargs="?",
help="The new tag's name")
argsp.add_argument("object",
default="HEAD",
nargs="?",
help="The object the new tag will point to")
def cmd_tag(args: argparse.Namespace) -> None:
repo = repo_find()
if args.name:
tag_create(repo,
args.name,
args.object,
type="object" if args.create_tag_object else "ref")
else:
refs = ref_list(repo)
show_ref(repo, refs["tags"], with_hash=False)
def tag_create(repo: GitRepository, name: str, ref: str,
create_tag_object: bool = False) -> None:
# get the GitObject from the object reference
sha = object_find(repo, ref)
if create_tag_object:
# create tag object (commit)
tag = GitTag(repo)
tag.kvlm = collections.OrderedDict()
tag.kvlm[b'object'] = sha.encode()
tag.kvlm[b'type'] = b'commit'
tag.kvlm[b'tag'] = name.encode()
# Feel free to let the user give their name!
# Notice you can fix this after commit, read on!
tag.kvlm[b'tagger'] = b'Wyag <wyag@example.com>'
# …and a tag message!
tag.kvlm[None] = b"A tag generated by wyag, which won't let you customize the message!"
tag_sha = object_write(tag)
# create reference
ref_create(repo, "tags/" + name, tag_sha)
else:
# create lightweight tag (ref)
ref_create(repo, "tags/" + name, sha)
def ref_create(repo: GitRepository, ref_name: str, sha: str) -> None:
with open(repo_file(repo, "refs/" + ref_name), 'w') as fp:
fp.write(sha + "\n")
def object_resolve(repo: GitRepository, name: str) -> list[str] | None:
"""Resolve name to an object hash in repo.
This function is aware of:
- the HEAD literal
- short and long hashes
- tags
- branches
- remote branches"""
candidates: list[str] = list()
hashRE = re.compile(r"^[0-9A-Fa-f]{4,40}$")
# Empty string? Abort.
if not name.strip():
return None
# Head is nonambiguous
if name == "HEAD":
return [ ref_resolve(repo, "HEAD") ]
# If it's a hex string, try for a hash.
if hashRE.match(name):
# This may be a hash, either small or full. 4 seems to be the
# minimal length for git to consider something a short hash.
# This limit is documented in man git-rev-parse
name = name.lower()
prefix = name[0:2]
path = repo_dir(repo, "objects", prefix, mkdir=False)
if path:
rem = name[2:]
for f in os.listdir(path):
if f.startswith(rem):
# Notice a string startswith() itself, so this
# works for full hashes.
candidates.append(prefix + f)
# Try for references.
as_tag = ref_resolve(repo, "refs/tags/" + name)
if as_tag: # Did we find a tag?
candidates.append(as_tag)
as_branch = ref_resolve(repo, "refs/heads/" + name)
if as_branch: # Did we find a branch?
candidates.append(as_branch)
return candidates
def object_find(repo: GitRepository, name: str, fmt: bytes | None = None,
follow: bool = True) -> str | None:
sha = object_resolve(repo, name)
if not sha:
raise Exception("No such reference {0}.".format(name))
if len(sha) > 1:
raise Exception("Ambiguous reference {0}: Candidates are:\n - {1}.".format(name, "\n - ".join(sha)))
sha = sha[0]
if not fmt:
return sha
while True:
obj = object_read(repo, sha)
# ^^^^^^^^^^^ < this is a bit agressive: we're reading
# the full object just to get its type. And we're doing
# that in a loop, albeit normally short. Don't expect
# high performance here.
if obj.fmt == fmt:
return sha
if not follow:
return None
# Follow tags
if obj.fmt == b'tag':
sha = obj.kvlm[b'object'].decode("ascii")
elif obj.fmt == b'commit' and fmt == b'tree':
sha = obj.kvlm[b'tree'].decode("ascii")
else:
return None
argsp = argsubparsers.add_parser(
"rev-parse",
help="Parse revision (or other objects) identifiers")
argsp.add_argument("--wyag-type",
metavar="type",
dest="type",
choices=["blob", "commit", "tag", "tree"],
default=None,
help="Specify the expected type")
argsp.add_argument("name",
help="The name to parse")
def cmd_rev_parse(args: argparse.Namespace) -> None:
if args.type:
fmt = args.type.encode()
else:
fmt = None
repo = repo_find()
print (object_find(repo, args.name, fmt, follow=True))
class GitIndexEntry (object):
ctime: tuple[int, int] | None
mtime: tuple[int, int] | None
dev: int | None
ino: int | None
mode_type: int | None
mode_perms: int | None
uid: int | None
gid: int | None
fsize: int | None
sha: str | None
flag_assume_valid: bool | None
flag_stage: int | None
name: str | None
def __init__(self, ctime: tuple[int, int] | None = None,
mtime: tuple[int, int] | None = None,
dev: int | None = None,
ino: int | None = None,
mode_type: int | None = None,
mode_perms: int | None = None,
uid: int | None = None,
gid: int | None = None,
fsize: int | None = None,
sha: str | None = None,
flag_assume_valid: bool | None = None,
flag_stage: int | None = None,
name: str | None = None) -> None:
# The last time a file's metadata changed. This is a pair
# (timestamp in seconds, nanoseconds)
self.ctime = ctime
# The last time a file's data changed. This is a pair
# (timestamp in seconds, nanoseconds)
self.mtime = mtime
# The ID of device containing this file
self.dev = dev
# The file's inode number
self.ino = ino
# The object type, either b1000 (regular), b1010 (symlink),
# b1110 (gitlink).
self.mode_type = mode_type
# The object permissions, an integer.
self.mode_perms = mode_perms
# User ID of owner
self.uid = uid
# Group ID of ownner
self.gid = gid
# Size of this object, in bytes
self.fsize = fsize
# The object's SHA
self.sha = sha
self.flag_assume_valid = flag_assume_valid
self.flag_stage = flag_stage
# Name of the object (full path this time!)
self.name = name
class GitIndex (object):
version: int | None = None
entries: list[GitIndexEntry] = []
# ext = None
# sha = None
def __init__(self, version: int = 2,
entries: list[GitIndexEntry] | None = None) -> None:
if not entries:
entries = list()
self.version = version
self.entries = entries
def index_read(repo: GitRepository) -> GitIndex:
index_file = repo_file(repo, "index")
# New repositories have no index!
if not os.path.exists(index_file):
return GitIndex()
with open(index_file, 'rb') as f:
raw = f.read()
header = raw[:12]
signature = header[:4]
assert signature == b"DIRC" # Stands for "DirCache"
version = int.from_bytes(header[4:8], "big")
assert version == 2, "wyag only supports index file version 2"
count = int.from_bytes(header[8:12], "big")
entries: list[GitIndexEntry] = list()
content = raw[12:]
idx = 0
for i in range(0, count):
# Read creation time, as a unix timestamp (seconds since
# 1970-01-01 00:00:00, the "epoch")
ctime_s = int.from_bytes(content[idx: idx+4], "big")
# Read creation time, as nanoseconds after that timestamps,
# for extra precision.
ctime_ns = int.from_bytes(content[idx+4: idx+8], "big")
# Same for modification time: first seconds from epoch.
mtime_s = int.from_bytes(content[idx+8: idx+12], "big")
# Then extra nanoseconds
mtime_ns = int.from_bytes(content[idx+12: idx+16], "big")
# Device ID
dev = int.from_bytes(content[idx+16: idx+20], "big")
# Inode
ino = int.from_bytes(content[idx+20: idx+24], "big")
# Ignored.
unused = int.from_bytes(content[idx+24: idx+26], "big")
assert 0 == unused
mode = int.from_bytes(content[idx+26: idx+28], "big")
mode_type = mode >> 12
assert mode_type in [0b1000, 0b1010, 0b1110]
mode_perms = mode & 0b0000000111111111
# User ID
uid = int.from_bytes(content[idx+28: idx+32], "big")
# Group ID
gid = int.from_bytes(content[idx+32: idx+36], "big")
# Size
fsize = int.from_bytes(content[idx+36: idx+40], "big")
# SHA (object ID). We'll store it as a lowercase hex string
# for consistency.
sha = format(int.from_bytes(content[idx+40: idx+60], "big"), "040x")
# Flags we're going to ignore
flags = int.from_bytes(content[idx+60: idx+62], "big")
# Parse flags
flag_assume_valid = (flags & 0b1000000000000000) != 0
flag_extended = (flags & 0b0100000000000000) != 0
assert not flag_extended
flag_stage = flags & 0b0011000000000000
# Length of the name. This is stored on 12 bits, some max
# value is 0xFFF, 4095. Since names can occasionally go
# beyond that length, git treats 0xFFF as meaning at least
# 0xFFF, and looks for the final 0x00 to find the end of the
# name --- at a small, and probably very rare, performance
# cost.
name_length = flags & 0b0000111111111111
# We've read 62 bytes so far.
idx += 62
if name_length < 0xFFF:
assert content[idx + name_length] == 0x00
raw_name = content[idx:idx+name_length]
idx += name_length + 1
else:
print("Notice: Name is 0x{:X} bytes long.".format(name_length))
# This probably wasn't tested enough. It works with a
# path of exactly 0xFFF bytes. Any extra bytes broke
# something between git, my shell and my filesystem.
null_idx = content.find(b'\x00', idx + 0xFFF)
raw_name = content[idx: null_idx]
idx = null_idx + 1
# Just parse the name as utf8.
name = raw_name.decode("utf8")
# Data is padded on multiples of eight bytes for pointer
# alignment, so we skip as many bytes as we need for the next
# read to start at the right position.
idx = 8 * ceil(idx / 8)
# And we add this entry to our list.
entries.append(GitIndexEntry(ctime=(ctime_s, ctime_ns),
mtime=(mtime_s, mtime_ns),
dev=dev,
ino=ino,
mode_type=mode_type,
mode_perms=mode_perms,
uid=uid,
gid=gid,
fsize=fsize,
sha=sha,
flag_assume_valid=flag_assume_valid,
flag_stage=flag_stage,
name=name))
return GitIndex(version=version, entries=entries)
argsp = argsubparsers.add_parser("ls-files", help = "List all the stage files")
argsp.add_argument("--verbose", action="store_true", help="Show everything.")
def cmd_ls_files(args: argparse.Namespace) -> None:
repo = repo_find()
index = index_read(repo)
if args.verbose:
print("Index file format v{}, containing {} entries.".format(index.version, len(index.entries)))
for e in index.entries:
print(e.name)
if args.verbose:
print(" {} with perms: {:o}".format(
{ 0b1000: "regular file",
0b1010: "symlink",
0b1110: "git link" }[e.mode_type],
e.mode_perms))
print(" on blob: {}".format(e.sha))
print(" created: {}.{}, modified: {}.{}".format(
datetime.fromtimestamp(e.ctime[0])
, e.ctime[1]
, datetime.fromtimestamp(e.mtime[0])
, e.mtime[1]))
print(" device: {}, inode: {}".format(e.dev, e.ino))
print(" user: {} ({}) group: {} ({})".format(
pwd.getpwuid(e.uid).pw_name,
e.uid,
grp.getgrgid(e.gid).gr_name,
e.gid))
print(" flags: stage={} assume_valid={}".format(
e.flag_stage,
e.flag_assume_valid))
argsp = argsubparsers.add_parser("check-ignore", help = "Check path(s) against ignore rules.")
argsp.add_argument("path", nargs="+", help="Paths to check")
def cmd_check_ignore(args: argparse.Namespace) -> None:
repo = repo_find()
rules = gitignore_read(repo)
for path in args.path:
if check_ignore(rules, path):
print(path)
def gitignore_parse1(raw: str) -> tuple[str, bool] | None:
raw = raw.strip() # Remove leading/trailing spaces
if not raw or raw[0] == "#":
return None
elif raw[0] == "!":
return (raw[1:], False)
elif raw[0] == "\\":
return (raw[1:], True)
else:
return (raw, True)
def gitignore_parse(lines: list[str]) -> list[tuple[str, bool]]:
ret: list[tuple[str, bool]] = list()
for line in lines:
parsed = gitignore_parse1(line)
if parsed:
ret.append(parsed)
return ret
class GitIgnore(object):
absolute: list[list[tuple[str, bool]]] | None = None
scoped: dict[str, list[tuple[str, bool]]] | None = None
def __init__(self, absolute: list[list[tuple[str, bool]]],
scoped: dict[str, list[tuple[str, bool]]]) -> None:
self.absolute = absolute
self.scoped = scoped
def gitignore_read(repo: GitRepository) -> GitIgnore:
ret = GitIgnore(absolute=list(), scoped=dict())
# Read local configuration in .git/info/exclude
repo_file = os.path.join(repo.gitdir, "info/exclude")
if os.path.exists(repo_file):
with open(repo_file, "r") as f:
ret.absolute.append(gitignore_parse(f.readlines()))
# Global configuration
if "XDG_CONFIG_HOME" in os.environ:
config_home = os.environ["XDG_CONFIG_HOME"]
else:
config_home = os.path.expanduser("~/.config")
global_file = os.path.join(config_home, "git/ignore")
if os.path.exists(global_file):
with open(global_file, "r") as f:
ret.absolute.append(gitignore_parse(f.readlines()))
# .gitignore files in the index
index = index_read(repo)
for entry in index.entries:
if entry.name == ".gitignore" or entry.name.endswith("/.gitignore"):
dir_name = os.path.dirname(entry.name)
contents = object_read(repo, entry.sha)
lines = contents.blobdata.decode("utf8").splitlines()
ret.scoped[dir_name] = gitignore_parse(lines)
return ret
def check_ignore1(rules: list[tuple[str, bool]], path: str) -> bool | None:
result: bool | None = None
for (pattern, value) in rules:
if fnmatch(path, pattern):
result = value
return result
def check_ignore_scoped(rules: dict[str, list[tuple[str, bool]]],
path: str) -> bool | None:
parent = os.path.dirname(path)
while True:
if parent in rules:
result = check_ignore1(rules[parent], path)
if result != None:
return result
if parent == "":
break
parent = os.path.dirname(parent)
return None
def check_ignore_absolute(rules: list[list[tuple[str, bool]]],
path: str) -> bool | None:
parent = os.path.dirname(path)
for ruleset in rules:
result = check_ignore1(ruleset, path)
if result != None:
return result
return False # This is a reasonable default at this point.
def check_ignore(rules: GitIgnore, path: str) -> bool | None:
if os.path.isabs(path):
raise Exception("This function requires path to be relative to the repository's root")
result = check_ignore_scoped(rules.scoped, path)
if result != None:
return result
return check_ignore_absolute(rules.absolute, path)
argsp = argsubparsers.add_parser("status", help = "Show the working tree status.")
def cmd_status(_: argparse.Namespace) -> None:
repo = repo_find()
index = index_read(repo)
cmd_status_branch(repo)
cmd_status_head_index(repo, index)
print()
cmd_status_index_worktree(repo, index)
def branch_get_active(repo: GitRepository) -> str | bool:
with open(repo_file(repo, "HEAD"), "r") as f:
head = f.read()
if head.startswith("ref: refs/heads/"):
return(head[16:-1])
else:
return False
def cmd_status_branch(repo: GitRepository) -> None:
branch = branch_get_active(repo)
if branch:
print("On branch {}.".format(branch))
else:
print("HEAD detached at {}".format (object_find(repo, "HEAD")))
def tree_to_dict(repo: GitRepository, ref: str, prefix: str = "") -> dict[str, str]:
ret: dict[str, str] = dict()
tree_sha = object_find(repo, ref, fmt=b"tree")
tree = object_read(repo, tree_sha)
for leaf in tree.items:
full_path = os.path.join(prefix, leaf.path)
# We read the object to extract its type (this is uselessly
# expensive: we could just open it as a file and read the
# first few bytes)
is_subtree = leaf.mode.startswith(b'04')
# Depending on the type, we either store the path (if it's a
# blob, so a regular file), or recurse (if it's another tree,
# so a subdir)
if is_subtree:
ret.update(tree_to_dict(repo, leaf.sha, full_path))
else:
ret[full_path] = leaf.sha
return ret
def cmd_status_head_index(repo: GitRepository, index: GitIndex) -> None:
print("Changes to be committed:")
head = tree_to_dict(repo, "HEAD")
for entry in index.entries:
if entry.name in head:
if head[entry.name] != entry.sha:
print(" modified:", entry.name)
del head[entry.name] # Delete the key
else:
print(" added: ", entry.name)
# Keys still in HEAD are files that we haven't met in the index,
# and thus have been deleted.
for entry in head.keys():
print(" deleted: ", entry)
def cmd_status_index_worktree(repo: GitRepository, index: GitIndex) -> None:
print("Changes not staged for commit:")
ignore = gitignore_read(repo)
gitdir_prefix = repo.gitdir + os.path.sep
all_files = list()
# We begin by walking the filesystem
for (root, _, files) in os.walk(repo.worktree, True):
if root==repo.gitdir or root.startswith(gitdir_prefix):
continue
for f in files:
full_path = os.path.join(root, f)
rel_path = os.path.relpath(full_path, repo.worktree)
all_files.append(rel_path)
# We now traverse the index, and compare real files with the cached
# versions.
for entry in index.entries:
full_path = os.path.join(repo.worktree, entry.name)
# That file *name* is in the index
if not os.path.exists(full_path):
print(" deleted: ", entry.name)
else:
stat = os.stat(full_path)
# Compare metadata
ctime_ns = entry.ctime[0] * 10**9 + entry.ctime[1]
mtime_ns = entry.mtime[0] * 10**9 + entry.mtime[1]
if (stat.st_ctime_ns != ctime_ns) or (stat.st_mtime_ns != mtime_ns):
# If different, deep compare.
# @FIXME This *will* crash on symlinks to dir.
with open(full_path, "rb") as fd:
new_sha = object_hash(fd, b"blob", None)
# If the hashes are the same, the files are actually the same.
same = entry.sha == new_sha
if not same:
print(" modified:", entry.name)
if entry.name in all_files:
all_files.remove(entry.name)
print()
print("Untracked files:")
for f in all_files:
# @TODO If a full directory is untracked, we should display
# its name without its contents.
if not check_ignore(ignore, f):
print(" ", f)
def index_write(repo: GitRepository, index: GitIndex) -> None:
with open(repo_file(repo, "index"), "wb") as f:
# HEADER
# Write the magic bytes.
f.write(b"DIRC")
# Write version number.
f.write(index.version.to_bytes(4, "big"))
# Write the number of entries.
f.write(len(index.entries).to_bytes(4, "big"))
# ENTRIES
idx = 0
for e in index.entries:
f.write(e.ctime[0].to_bytes(4, "big"))
f.write(e.ctime[1].to_bytes(4, "big"))
f.write(e.mtime[0].to_bytes(4, "big"))
f.write(e.mtime[1].to_bytes(4, "big"))
f.write(e.dev.to_bytes(4, "big"))
f.write(e.ino.to_bytes(4, "big"))
# Mode
mode = (e.mode_type << 12) | e.mode_perms
f.write(mode.to_bytes(4, "big"))
f.write(e.uid.to_bytes(4, "big"))
f.write(e.gid.to_bytes(4, "big"))
f.write(e.fsize.to_bytes(4, "big"))
# @FIXME Convert back to int.
f.write(int(e.sha, 16).to_bytes(20, "big"))
flag_assume_valid = 0x1 << 15 if e.flag_assume_valid else 0
name_bytes = e.name.encode("utf8")
bytes_len = len(name_bytes)
if bytes_len >= 0xFFF:
name_length = 0xFFF
else:
name_length = bytes_len
# We merge back three pieces of data (two flags and the
# length of the name) on the same two bytes.
f.write((flag_assume_valid | e.flag_stage | name_length).to_bytes(2, "big"))
# Write back the name, and a final 0x00.
f.write(name_bytes)
f.write((0).to_bytes(1, "big"))
idx += 62 + len(name_bytes) + 1
# Add padding if necessary.
if idx % 8 != 0:
pad = 8 - (idx % 8)
f.write((0).to_bytes(pad, "big"))
idx += pad
argsp = argsubparsers.add_parser("rm", help="Remove files from the working tree and the index.")
argsp.add_argument("path", nargs="+", help="Files to remove")
def cmd_rm(args: argparse.Namespace) -> None:
repo = repo_find()
rm(repo, args.path)
def rm(repo: GitRepository, paths: list[str], delete: bool = True,
skip_missing: bool = False) -> None:
# Find and read the index
index = index_read(repo)
worktree = repo.worktree + os.sep
# Make paths absolute
abspaths = list()
for path in paths:
abspath = os.path.abspath(path)
if abspath.startswith(worktree):
abspaths.append(abspath)
else:
raise Exception("Cannot remove paths outside of worktree: {}".format(paths))
kept_entries = list()
remove = list()
for e in index.entries:
full_path = os.path.join(repo.worktree, e.name)
if full_path in abspaths:
remove.append(full_path)
abspaths.remove(full_path)
else:
kept_entries.append(e) # Preserve entry
if len(abspaths) > 0 and not skip_missing:
raise Exception("Cannot remove paths not in the index: {}".format(abspaths))
if delete:
for path in remove:
os.unlink(path)
index.entries = kept_entries
index_write(repo, index)
argsp = argsubparsers.add_parser("add", help = "Add files contents to the index.")
argsp.add_argument("path", nargs="+", help="Files to add")
def cmd_add(args: argparse.Namespace) -> None:
repo = repo_find()
add(repo, args.path)
def add(repo: GitRepository, paths: list[str], delete: bool = True,
skip_missing: bool = False) -> None:
# First remove all paths from the index, if they exist.
rm (repo, paths, delete=False, skip_missing=True)
worktree = repo.worktree + os.sep
# Convert the paths to pairs: (absolute, relative_to_worktree).
# Also delete them from the index if they're present.
clean_paths = list()
for path in paths:
abspath = os.path.abspath(path)
if not (abspath.startswith(worktree) and os.path.isfile(abspath)):
raise Exception("Not a file, or outside the worktree: {}".format(paths))
relpath = os.path.relpath(abspath, repo.worktree)
clean_paths.append((abspath, relpath))
# Find and read the index. It was modified by rm. (This isn't
# optimal, good enough for wyag!)
#
# @FIXME, though: we could just
# move the index through commands instead of reading and writing
# it over again.
index = index_read(repo)
for (abspath, relpath) in clean_paths:
with open(abspath, "rb") as fd:
sha = object_hash(fd, b"blob", repo)
stat = os.stat(abspath)
ctime_s = int(stat.st_ctime)
ctime_ns = stat.st_ctime_ns % 10**9
mtime_s = int(stat.st_mtime)
mtime_ns = stat.st_mtime_ns % 10**9
entry = GitIndexEntry(ctime=(ctime_s, ctime_ns), mtime=(mtime_s, mtime_ns), dev=stat.st_dev, ino=stat.st_ino,
mode_type=0b1000, mode_perms=0o644, uid=stat.st_uid, gid=stat.st_gid,
fsize=stat.st_size, sha=sha, flag_assume_valid=False,
flag_stage=False, name=relpath)
index.entries.append(entry)
# Write the index back
index_write(repo, index)
argsp = argsubparsers.add_parser("commit", help="Record changes to the repository.")
argsp.add_argument("-m",
metavar="message",
dest="message",
help="Message to associate with this commit.")
def gitconfig_read() -> configparser.ConfigParser:
xdg_config_home = os.environ["XDG_CONFIG_HOME"] if "XDG_CONFIG_HOME" in os.environ else "~/.config"
configfiles = [
os.path.expanduser(os.path.join(xdg_config_home, "git/config")),
os.path.expanduser("~/.gitconfig")
]
config = configparser.ConfigParser()
config.read(configfiles)
return config
def gitconfig_user_get(config: configparser.ConfigParser) -> str | None:
if "user" in config:
if "name" in config["user"] and "email" in config["user"]:
return "{} <{}>".format(config["user"]["name"], config["user"]["email"])
return None
def tree_from_index(repo: GitRepository, index: GitIndex) -> str | None:
contents: dict[str, list[Any]] = dict()
contents[""] = list()
# Enumerate entries, and turn them into a dictionary where keys
# are directories, and values are lists of directory contents.
for entry in index.entries:
dirname = os.path.dirname(entry.name)
# We create all dictonary entries up to root (""). We need
# them *all*, because even if a directory holds no files it
# will contain at least a tree.
key = dirname
while key != "":
if not key in contents:
contents[key] = list()
key = os.path.dirname(key)
# For now, simply store the entry in the list.
contents[dirname].append(entry)
# Get keys (= directories) and sort them by length, descending.
# This means that we'll always encounter a given path before its
# parent, which is all we need, since for each directory D we'll
# need to modify its parent P to add D's tree.
sorted_paths = sorted(contents.keys(), key=len, reverse=True)
# This variable will store the current tree's SHA-1. After we're
# done iterating over our dict, it will contain the hash for the
# root tree.
sha = None
# We ge through the sorted list of paths (dict keys)
for path in sorted_paths:
# Prepare a new, empty tree object
tree = GitTree()
# Add each entry to our new tree, in turn
for entry in contents[path]:
# An entry can be a normal GitIndexEntry read from the
# index, or a tree we've created.
if isinstance(entry, GitIndexEntry): # Regular entry (a file)
# We transcode the mode: the entry stores it as integers,
# we need an octal ASCII representation for the tree.
leaf_mode = "{:02o}{:04o}".format(entry.mode_type, entry.mode_perms).encode("ascii")
leaf = GitTreeLeaf(mode = leaf_mode, path=os.path.basename(entry.name), sha=entry.sha)
else: # Tree. We've stored it as a pair: (basename, SHA)
leaf = GitTreeLeaf(mode = b"040000", path=entry[0], sha=entry[1])
tree.items.append(leaf)
# Write the new tree object to the store.
sha = object_write(tree, repo)
# Add the new tree hash to the current dictionary's parent, as
# a pair (basename, SHA)
parent = os.path.dirname(path)
base = os.path.basename(path) # The name without the path, eg main.go for src/main.go
contents[parent].append((base, sha))
return sha
def commit_create(repo: GitRepository, tree: str, parent: str | None,
author: str, timestamp: datetime, message: str) -> str:
commit = GitCommit() # Create the new commit object.
commit.kvlm[b"tree"] = tree.encode("ascii")
if parent:
commit.kvlm[b"parent"] = parent.encode("ascii")
# Format timezone
offset = int(timestamp.astimezone().utcoffset().total_seconds())
hours = offset // 3600
minutes = (offset % 3600) // 60
tz = "{}{:02}{:02}".format("+" if offset > 0 else "-", hours, minutes)
author = author + timestamp.strftime(" %s ") + tz
commit.kvlm[b"author"] = author.encode("utf8")
commit.kvlm[b"committer"] = author.encode("utf8")
commit.kvlm[None] = message.encode("utf8")
return object_write(commit, repo)
def cmd_commit(args: argparse.Namespace) -> None:
repo = repo_find()
index = index_read(repo)
# Create trees, grab back SHA for the root tree.
tree = tree_from_index(repo, index)
# Create the commit object itself
commit = commit_create(repo,
tree,
object_find(repo, "HEAD"),
gitconfig_user_get(gitconfig_read()),
datetime.now(),
args.message)
# Update HEAD so our commit is now the tip of the active branch.
active_branch = branch_get_active(repo)
if active_branch: # If we're on a branch, we update refs/heads/BRANCH
with open(repo_file(repo, os.path.join("refs/heads", active_branch)), "w") as fd:
fd.write(commit + "\n")
else: # Otherwise, we update HEAD itself.
with open(repo_file(repo, "HEAD"), "w") as fd:
fd.write("\n")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment