9. 暂存区和索引,第二部分:暂存和提交
好的。让我们来创建提交。
我们几乎具备了所有需要的条件,除了最后三个要点:
- 我们需要命令来修改索引,以便我们的提交不仅仅是父提交的副本。这些命令是
add
和rm
。 - 这些命令需要将修改后的索引写回,因为我们是从索引中提交的。
- 显然,我们还需要
commit
函数及其相关的wyag commit
命令。
9.1. 写入索引
我们将首先写入索引。大致上,我们只是将所有内容序列化回二进制。这有点繁琐,但代码应该是直接明了的。我会将一些细节留给注释,但实际上这只是 index_read
的反向操作——如有需要,请参考它和 GitIndexEntry
类。
def index_write(repo, index):
with open(repo_file(repo, "index"), "wb") as f:
# 头部
# 写入魔术字节。
f.write(b"DIRC")
# 写入版本号。
f.write(index.version.to_bytes(4, "big"))
# 写入条目数量。
f.write(len(index.entries).to_bytes(4, "big"))
# 条目
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 = (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 转换回整数。
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
# 我们将三个数据片段(两个标志和名称长度)合并到同两个字节中。
f.write((flag_assume_valid | e.flag_stage | name_length).to_bytes(2, "big"))
# 写入名称和最后的 0x00。
f.write(name_bytes)
f.write((0).to_bytes(1, "big"))
idx += 62 + len(name_bytes) + 1
# 如有必要,添加填充。
if idx % 8 != 0:
pad = 8 - (idx % 8)
f.write((0).to_bytes(pad, "big"))
idx += pad
9.2. rm 命令
对索引进行的最简单修改是从中移除一个条目,这意味着下一个提交将不包括该文件。这就是 git rm
命令的作用。
危险
git rm
是破坏性的,wyag rm
也是如此。该命令不仅修改索引,还会从工作区中删除文件。与 git 不同,wyag rm
不关心它移除的文件是否已保存。请谨慎操作。
rm
接受一个参数,即要移除的路径列表:
argsp = argsubparsers.add_parser("rm", help="从工作树和索引中移除文件。")
argsp.add_argument("path", nargs="+", help="要移除的文件")
def cmd_rm(args):
repo = repo_find()
rm(repo, args.path)
rm
函数稍微长一些,但它非常简单。它接受一个仓库和一个路径列表,读取该仓库的索引,并移除与该列表匹配的索引条目。可选参数控制函数是否实际删除文件,以及如果某些路径在索引中不存在,是否应中止操作(这两个参数用于 add
,在 wyag rm
命令中不暴露)。
def rm(repo, paths, delete=True, skip_missing=False):
# 查找并读取索引
index = index_read(repo)
worktree = repo.worktree + os.sep
# 将路径转换为绝对路径
abspaths = list()
for path in paths:
abspath = os.path.abspath(path)
if abspath.startswith(worktree):
abspaths.append(abspath)
else:
raise Exception("无法移除工作树外的路径:{}".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) # 保留条目
if len(abspaths) > 0 and not skip_missing:
raise Exception("无法移除索引中不存在的路径:{}".format(abspaths))
if delete:
for path in remove:
os.unlink(path)
index.entries = kept_entries
index_write(repo, index)
现在我们可以使用 wyag rm
删除文件。
9.3. add 命令
添加操作比移除操作稍微复杂一些,但没有什么是我们不熟悉的。将文件添加到暂存区是一个三步操作:
- 首先,如果已有索引条目,则移除该条目,但不删除文件本身(这就是我们刚刚编写的
rm
函数包含可选参数的原因)。 - 然后对文件进行哈希处理,生成一个 blob 对象。
- 创建该条目。
- 最后,当然要将修改后的索引写回。
首先是接口。没有什么惊喜,wyag add PATH ...
,其中 PATH 是一个或多个要暂存的文件。桥接函数非常简单。
argsp = argsubparsers.add_parser("add", help="将文件内容添加到索引。")
argsp.add_argument("path", nargs="+", help="要添加的文件")
def cmd_add(args):
repo = repo_find()
add(repo, args.path)
与 rm
的主要区别在于 add
需要创建一个索引条目。这并不难:我们只需对文件进行 stat()
操作,并将元数据复制到索引的字段中(stat()
返回索引存储的元数据:创建/修改时间等)。
def add(repo, paths, delete=True, skip_missing=False):
# 首先从索引中移除所有路径(如果存在)。
rm(repo, paths, delete=False, skip_missing=True)
worktree = repo.worktree + os.sep
# 将路径转换为对: (绝对路径,相对工作树路径)。
# 如果它们在索引中,则也将其删除。
clean_paths = list()
for path in paths:
abspath = os.path.abspath(path)
if not (abspath.startswith(worktree) and os.path.isfile(abspath)):
raise Exception("不是文件,或不在工作树内:{}".format(paths))
relpath = os.path.relpath(abspath, repo.worktree)
clean_paths.append((abspath, relpath))
# 查找并读取索引。它已被 rm 修改。(这不是最优的,但对 wyag 足够了!)
#
# @FIXME: 我们本可以通过命令移动索引,而不是读取和重新写入它。
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)
# 将索引写回
index_write(repo, index)
9.4. commit 命令
现在我们已经修改了索引,也就是实际的 暂存更改,我们只需要将这些更改转换为一个提交。这就是 commit
的作用。
argsp = argsubparsers.add_parser("commit", help="记录对仓库的更改。")
argsp.add_argument("-m",
metavar="message",
dest="message",
help="与此提交关联的消息。")
为此,我们首先需要将索引转换为树对象,生成并存储相应的提交对象,并将 HEAD 分支更新为新的提交(请记住:分支只是指向提交的引用)。
在进入有趣的细节之前,我们需要读取 Git 的配置,以获取用户的名字,作为作者和提交者。我们将使用之前用来读取仓库配置的 configparser
库。
def gitconfig_read():
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):
if "user" in config:
if "name" in config["user"] and "email" in config["user"]:
return "{} <{}>".format(config["user"]["name"], config["user"]["email"])
return None
现在进入有趣的部分。我们首先需要从索引构建一棵树。这并不困难,但请注意,虽然索引是平面的(它为整个工作树存储完整路径),而树是一个递归结构:它列出文件或其他树。为了将索引“反扁平化”为一棵树,我们将:
- 建立一个目录的字典(哈希映射)。键是来自工作树根的完整路径(如
assets/sprites/monsters/
),值是GitIndexEntry
的列表——该目录中的文件。此时,我们的字典仅包含 文件:目录仅作为其键。 - 遍历此列表,从最深的目录向上到根(深度实际上并不重要:我们只希望在看到每个目录的 父目录 之前看到它。为此,我们只需按 完整 路径长度从长到短对它们进行排序——父目录显然总是较短的)。例如,想象我们从
assets/sprites/monsters/
开始。 - 在每个目录下,我们使用其内容构建一棵树,比如
cacodemon.png
、imp.png
和baron-of-hell.png
。 - 将新树写入仓库。
- 然后将此树添加到该目录的父目录中。这意味着此时,
assets/sprites/
现在包含我们新树对象的 SHA-1 ID,名称为monsters
。 - 接着我们迭代下一个目录,比如
assets/sprites/keys
,在这里我们发现red.png
、blue.png
和yellow.png
,创建一棵树,存储该树,并在assets/sprites/
下以名称keys
添加该树的 SHA-1,依此类推。
由于树是递归的?因此我们构建的最后一棵树必然是根树(因为它的键长度为 0),最终将引用所有其他树,因此它将是我们唯一需要的树。我们只需返回其 SHA-1,就完成了。
由于这可能看起来有些复杂,让我们详细演示这个例子——随意跳过。在开始时,我们从索引构建的字典如下所示:
contents["assets/sprites/monsters"] =
[ cacodemon.png : GitIndexEntry
, imp.png : GitIndexEntry
, baron-of-hell.png : GitIndexEntry ]
contents["assets/sprites/keys"] =
[ red.png : GitIndexEntry
, blue.png : GitIndexEntry
, yellow.png : GitIndexEntry ]
contents["assets/sprites/"] =
[ hero.png : GitIndexEntry ]
contents["assets/"] = [] # 这里没有文件
contents[""] = # 根!
[ README: GitIndexEntry ]
我们按键长度从长到短的顺序进行迭代。我们遇到的第一个键是最长的,即 assets/sprites/monsters
。我们根据其内容构建一个新的树对象,将三个文件名(cacodemon.png
、imp.png
、baron-of-hell.png
)与它们对应的 blob 关联起来(树的叶子存储的数据 比 索引少——仅存储路径、模式和 blob。因此,以这种方式转换条目是容易的)。
注意,我们不需要关心存储这些文件的 内容:wyag add
确实根据需要创建了相应的 blob。我们需要将我们创建的 树 存储到对象库中,但我们可以假设 blob 已经在那里。
假设我们新生成的树哈希值,由直接来自 assets/sprites/monsters
的索引条目生成,哈希值为 426f894781bc3c38f1d26f8fd2c7f38ab8d21763
。我们 修改我们的字典,将这个新的树对象添加到目录的父级,像这样,所以现在剩下的遍历内容看起来是这样的:
contents["assets/sprites/keys"] = # <- 未修改。
[ red.png : GitIndexEntry
, blue.png : GitIndexEntry
, yellow.png : GitIndexEntry ]
contents["assets/sprites/"] =
[ hero.png : GitIndexEntry
, monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763 ] <- 看这里
contents["assets/"] = [] # 空
contents[""] = # 根!
[ README: GitIndexEntry ]
我们对下一个最长的键 assets/sprites/keys
做同样的操作,生成一个哈希为 b42788e087b1e94a0e69dcb7a4a243eaab802bb2
的树,因此:
contents["assets/sprites/"] =
[ hero.png : GitIndexEntry
, monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763
, keys : Tree b42788e087b1e94a0e69dcb7a4a243eaab802bb2 ]
contents["assets/"] = [] # 空
contents[""] = # 根!
[ README: GitIndexEntry ]
接着,我们从 assets/sprites
生成哈希为 6364113557ed681d775ccbd3c90895ed276956a2
的树,它现在包含我们的两个树和 hero.png
。
contents["assets/"] = [
sprites: Tree 6364113557ed681d775ccbd3c90895ed276956a2 ]
contents[""] = # 根!
[ README: GitIndexEntry ]
assets
反过来变成哈希为 4d35513cb6d2a816bc00505be926624440ebbddd
的树,因此:
contents[""] = # 根!
[ README: GitIndexEntry,
assets: 4d35513cb6d2a816bc00505be926624440ebbddd]
我们从最后一个键(带有 README
blob 和 assets
子树)生成一棵树,它的哈希值为 9352e52ff58fa9bf5a750f090af64c09fa6a3d93
。这就是我们的返回值:这棵树的内容与索引的内容相同。
这里是实际的函数:
def tree_from_index(repo, index):
contents = dict()
contents[""] = list()
# 枚举条目,并将它们转换为一个字典,其中键是目录,值是目录内容的列表。
for entry in index.entries:
dirname = os.path.dirname(entry.name)
# 我们创建所有到根目录 ("") 的字典条目。我们需要它们 *全部*,因为即使一个目录没有文件,它至少会包含一个树。
key = dirname
while key != "":
if key not in contents:
contents[key] = list()
key = os.path.dirname(key)
# 暂时将条目存储在列表中。
contents[dirname].append(entry)
# 获取键(即目录)并按长度降序排序。
# 这意味着我们总是会在其父目录之前遇到给定路径,这正是我们需要的,因为对于每个目录 D,我们需要修改其父目录 P 以添加 D 的树。
sorted_paths = sorted(contents.keys(), key=len, reverse=True)
# 这个变量将存储当前树的 SHA-1。完成遍历后,它将包含根树的哈希。
sha = None
# 我们遍历排序后的路径列表(字典键)
for path in sorted_paths:
# 准备一个新的空树对象
tree = GitTree()
# 将每个条目依次添加到我们的新树中
for entry in contents[path]:
# 条目可以是从索引读取的普通 GitIndexEntry,或者是我们创建的树。
if isinstance(entry, GitIndexEntry): # 普通条目(一个文件)
# 我们转换模式:条目将其存储为整数,我们需要树的八进制 ASCII 表示。
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: # 树。我们将其存储为一对: (basename, SHA)
leaf = GitTreeLeaf(mode=b"040000", path=entry[0], sha=entry[1])
tree.items.append(leaf)
# 将新的树对象写入存储。
sha = object_write(tree, repo)
# 将新的树哈希添加到当前字典的父目录,作为一对 (basename, SHA)
parent = os.path.dirname(path)
base = os.path.basename(path) # 不带路径的名称,例如 src/main.go 的 main.go
contents[parent].append((base, sha))
return sha
这部分比较复杂;我希望它足够清晰。从这里开始,创建提交对象和更新 HEAD 将会简单得多。只需记住,这个函数 做 的事情是构建和存储尽可能多的树对象,以表示索引,并返回根树的 SHA-1。
创建提交对象的函数足够简单,它只接受一些参数:树的哈希、父提交的哈希、作者的身份(一个字符串)、时间戳和时区差值,以及消息:
def commit_create(repo, tree, parent, author, timestamp, message):
commit = GitCommit() # 创建新的提交对象
commit.kvlm[b"tree"] = tree.encode("ascii")
if parent:
commit.kvlm[b"parent"] = parent.encode("ascii")
# 格式化时区
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)
剩下的就是 cmd_commit
,它是 wyag commit
命令的桥接函数:
def cmd_commit(args):
repo = repo_find()
index = index_read(repo)
# 创建树,获取根树的 SHA
tree = tree_from_index(repo, index)
# 创建提交对象
commit = commit_create(repo,
tree,
object_find(repo, "HEAD"),
gitconfig_user_get(gitconfig_read()),
datetime.now(),
args.message)
# 更新 HEAD,使我们的提交成为当前分支的顶端
active_branch = branch_get_active(repo)
if active_branch: # 如果我们在一个分支上,更新 refs/heads/BRANCH
with open(repo_file(repo, os.path.join("refs/heads", active_branch)), "w") as fd:
fd.write(commit + "\n")
else: # 否则,更新 HEAD 本身
with open(repo_file(repo, "HEAD"), "w") as fd:
fd.write("\n")
我们完成了!