8. 处理暂存区和索引文件
8.1. 什么是索引文件?
最后一步将引导我们进入提交的实际发生地(虽然实际创建提交是在下一节!)
你可能知道,在 Git 中进行提交时,首先要“暂存”一些更改,使用 git add
和 git rm
,然后才提交这些更改。最后一次提交和下一次提交之间的这个中间阶段称为 暂存区。
看起来自然的是使用提交或树对象来表示暂存区,但 Git 实际上使用的是一种完全不同的机制,即所谓的 索引文件。
在提交之后,索引文件可以看作是该提交的某种副本:它持有与对应树相同的路径/Blob 关联。但它还包含关于工作区中文件的额外信息,比如创建/修改时间,因此 git status
并不需要实际比较文件:它只需检查文件的修改时间是否与索引文件中存储的时间相同,只有在不相同时才会进行实际比较。
因此,你可以将索引文件视为一个三方关联列表:不仅包含路径与 Blob 的关联,还包含路径与实际文件系统条目的关联。
索引文件 的另一个重要特性是,与树不同,它可以表示不一致的状态,比如合并冲突,而树始终是完整且明确的表示。
当你提交时,Git 实际上是将索引文件转换为一个新的树对象。总结如下:
当仓库“干净”时,索引文件包含与 HEAD 提交完全相同的内容,以及对应文件系统条目的元数据。例如,它可能包含如下内容:
有一个名为
src/disp.c
的文件,其内容为 Blob 797441c76e59e28794458b39b0f1eff4c85f4fa0。实际的src/disp.c
文件在工作区中创建于 2023-07-15 15:28:29.168572151,最后修改于 2023-07-15 15:28:29.1689427709。它存储在设备 65026,inode 8922881 上。当你使用
git add
或git rm
时,索引文件会相应地被修改。在上述示例中,如果你修改了src/disp.c
并add
你的更改,索引文件将更新为新的 Blob ID(当然,Blob 本身也会在此过程中被创建),并且各种文件元数据也会被更新,以便git status
知道何时不需要比较文件内容。当你将这些更改
git commit
时,将从索引文件生成一个新的树对象,生成一个新的提交对象,并更新分支,然后完成。
备注
关于术语的说明
因此,暂存区和索引是同一个概念,但“暂存区”这个名称更像是 Git 用户可见的功能名称(可以用其他方式实现),是某种抽象;而“索引文件”则专指这一抽象功能在 Git 中的实际实现方式。
8.2. 解析索引
索引文件是 Git 仓库中最复杂的数据结构。其完整文档可以在 Git 源代码树的 Documentation/gitformat-index.txt
中找到;你可以在 GitHub 镜像上浏览。它由三部分组成:
- 一个包含格式版本号和索引条目数量的头部;
- 一系列已排序的条目,每个条目代表一个文件,填充到 8 字节的倍数;
- 一系列可选扩展,我们将忽略它们。
我们需要表示的第一件事是单个条目。它实际上包含了很多内容,具体细节将在注释中说明。值得注意的是,一个条目同时存储了与对象存储中的 blob 相关联的 SHA-1 和关于实际文件的许多元数据。这是因为 git/wyag status
需要确定索引中的哪些文件被修改:首先检查最后修改的时间戳并与已知值进行比较,效率更高,然后再比较实际文件。
class GitIndexEntry (object):
def __init__(self, ctime=None, mtime=None, dev=None, ino=None,
mode_type=None, mode_perms=None, uid=None, gid=None,
fsize=None, sha=None, flag_assume_valid=None,
flag_stage=None, name=None):
# 文件元数据最后一次更改的时间。 这是一个对
# (秒级时间戳,纳秒级时间戳)的元组
self.ctime = ctime
# 文件数据最后一次更改的时间。 这是一个对
# (秒级时间戳,纳秒级时间戳)的元组
self.mtime = mtime
# 包含此文件的设备 ID
self.dev = dev
# 文件的 inode 编号
self.ino = ino
# 对象类型,可以是 b1000(常规),b1010(符号链接),
# b1110(gitlink)。
self.mode_type = mode_type
# 对象权限,整数值。
self.mode_perms = mode_perms
# 拥有者的用户 ID
self.uid = uid
# 拥有者的组 ID
self.gid = gid
# 此对象的大小,以字节为单位
self.fsize = fsize
# 对象的 SHA
self.sha = sha
self.flag_assume_valid = flag_assume_valid
self.flag_stage = flag_stage
# 对象名称(这次是完整路径!)
self.name = name
索引文件是一个二进制文件,可能出于性能原因。格式相对简单,它以一个包含 DIRC
魔术字节、版本号和索引文件中条目总数的头部开始。我们创建 GitIndex
类来保存这些信息:
class GitIndex (object):
version = None
entries = []
# ext = None
# sha = None
def __init__(self, version=2, entries=None):
if not entries:
entries = list()
self.version = version
self.entries = entries
接下来是一个解析器,将索引文件读入这些对象。在读取了 12 字节的头部后,我们按照出现的顺序解析条目。一个条目以一组固定长度的数据开始,后面跟着一个可变长度的名称。
代码相当简单,但由于它在读取二进制格式,感觉比我们之前做的要复杂一些。我们大量使用 int.from_bytes(bytes, endianness)
来将原始字节读入整数,并使用少量的位操作来分离共享相同字节的数据。
这个格式可能是为了让索引文件能够直接通过 mmapp()
映射到内存,并作为 C 结构直接读取,从而在大多数情况下以 O(n) 时间构建索引。这种方法通常会在 C 语言中产生比在 Python 中更优雅的代码……
def index_read(repo):
index_file = repo_file(repo, "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" # 代表 "DirCache"
version = int.from_bytes(header[4:8], "big")
assert version == 2, "wyag 仅支持索引文件版本 2"
count = int.from_bytes(header[8:12], "big")
entries = list()
content = raw[12:]
idx = 0
for i in range(0, count):
# 读取创建时间,作为 UNIX 时间戳(自 1970-01-01 00:00:00 起的秒数)
ctime_s = int.from_bytes(content[idx: idx+4], "big")
# 读取创建时间,作为该时间戳后的纳秒数,以获得额外的精度
ctime_ns = int.from_bytes(content[idx+4: idx+8], "big")
# 同样处理修改时间:先是从纪元起的秒数
mtime_s = int.from_bytes(content[idx+8: idx+12], "big")
# 然后是额外的纳秒数
mtime_ns = int.from_bytes(content[idx+12: idx+16], "big")
# 设备 ID
dev = int.from_bytes(content[idx+16: idx+20], "big")
# inode
ino = int.from_bytes(content[idx+20: idx+24], "big")
# 忽略的字段
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
# 用户 ID
uid = int.from_bytes(content[idx+28: idx+32], "big")
# 组 ID
gid = int.from_bytes(content[idx+32: idx+36], "big")
# 大小
fsize = int.from_bytes(content[idx+36: idx+40], "big")
# SHA(对象 ID)。我们将其存储为小写的十六进制字符串,以保持一致性
sha = format(int.from_bytes(content[idx+40: idx+60], "big"), "040x")
# 我们将忽略的标志
flags = int.from_bytes(content[idx+60: idx+62], "big")
# 解析标志
flag_assume_valid = (flags & 0b1000000000000000) != 0
flag_extended = (flags & 0b0100000000000000) != 0
assert not flag_extended
flag_stage = flags & 0b0011000000000000
# 名称的长度。这是以 12 位存储的,最大值为 0xFFF,4095。由于名称有时可能超过该长度,git 将 0xFFF 视为表示至少 0xFFF,并寻找最终的 0x00 以找到名称的结束——这会带来小而可能非常罕见的性能损失。
name_length = flags & 0b0000111111111111
# 到目前为止我们已经读取了 62 字节。
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("注意:名称长度为 0x{:X} 字节。".format(name_length))
# 这可能没有经过足够的测试。它适用于长度恰好为 0xFFF 字节的路径。任何额外字节可能会在 git、我的 shell 和我的文件系统之间造成问题。
null_idx = content.find(b'\x00', idx + 0xFFF)
raw_name = content[idx:null_idx]
idx = null_idx + 1
# 将名称解析为 UTF-8
name = raw_name.decode("utf8")
# 数据按 8 字节的倍数填充以进行指针对齐,因此我们跳过需要的字节,以便下次读取从正确的位置开始。
idx = 8 * ceil(idx / 8)
# 然后我们将此条目添加到我们的列表中。
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)
8.3. ls-files 命令
git ls-files
显示暂存区中文件的名称,通常带有大量选项。我们的 ls-files
将简单得多,但我们会添加一个 --verbose
选项,这是 git 中不存在的,以便显示索引文件中的每一个信息。
argsp = argsubparsers.add_parser("ls-files", help="列出所有暂存文件")
argsp.add_argument("--verbose", action="store_true", help="显示所有信息。")
def cmd_ls_files(args):
repo = repo_find()
index = index_read(repo)
if args.verbose:
print("索引文件格式 v{}, 包含 {} 条目。".format(index.version, len(index.entries)))
for e in index.entries:
print(e.name)
if args.verbose:
print(" {},权限:{:o}".format(
{ 0b1000: "常规文件",
0b1010: "符号链接",
0b1110: "git 链接" }[e.mode_type],
e.mode_perms))
print(" 对应的 blob: {}".format(e.sha))
print(" 创建时间:{}.{}, 修改时间:{}.{}".format(
datetime.fromtimestamp(e.ctime[0]),
e.ctime[1],
datetime.fromtimestamp(e.mtime[0]),
e.mtime[1]))
print(" 设备:{}, inode: {}".format(e.dev, e.ino))
print(" 用户:{} ({}) 组:{} ({})".format(
pwd.getpwuid(e.uid).pw_name,
e.uid,
grp.getgrgid(e.gid).gr_name,
e.gid))
print(" 标志:stage={} assume_valid={}".format(
e.flag_stage,
e.flag_assume_valid))
如果你运行 ls-files,你会注意到在“干净”的工作区(未修改的 HEAD
检出)中,它列出了 HEAD
上的所有文件。再次强调,索引并不是从 HEAD
提交的一个增量(一组差异),而是以不同的格式作为它的一个副本。
8.4. 绕道:check-ignore 命令
我们想要编写 status
,但 status
需要了解忽略规则,这些规则存储在各种 .gitignore
文件中。因此,我们首先需要在 wyag
中添加一些基本的忽略文件支持。我们将以 check-ignore
命令的形式暴露这一支持,该命令接受一个路径列表,并输出那些应该被忽略的路径。
命令解析器同样很简单:
argsp = argsubparsers.add_parser("check-ignore", help="检查路径是否符合忽略规则。")
argsp.add_argument("path", nargs="+", help="待检查的路径")
函数也同样简单:
def cmd_check_ignore(args):
repo = repo_find()
rules = gitignore_read(repo)
for path in args.path:
if check_ignore(rules, path):
print(path)
当然,我们调用的大多数函数在 wyag 中还不存在。我们将首先编写一个读取忽略文件规则的函数 gitignore_read()
。这些规则的语法相当简单:每行都是一个排除模式,匹配该模式的文件将被 status
、add -A
等忽略。不过,有三个特殊情况:
- 以感叹号
!
开头的行会 否定 模式(匹配该模式的文件会被 包含,即使它们之前被忽略)。 - 以井号
#
开头的行是注释,会被跳过。 - 行首的反斜杠
\
将!
和#
视为字面字符。
首先,单个模式的解析器。该解析器返回一对值:模式本身,以及一个布尔值,用于指示匹配该模式的文件是 应该 被排除 (True
) 还是包含 (False
)。换句话说,如果模式以 !
开头,则返回 False
,否则返回 True
。
def gitignore_parse1(raw):
raw = raw.strip() # 去除前后空格
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)
解析文件的过程就是收集该文件中的所有规则。请注意,这个函数并不解析 文件,而只是解析行的列表:这是因为我们也需要从 git blobs 中读取规则,而不仅仅是常规文件。
def gitignore_parse(lines):
ret = list()
for line in lines:
parsed = gitignore_parse1(line)
if parsed:
ret.append(parsed)
return ret
最后,我们需要做的就是收集各种忽略文件。这些文件分为两种:
- 一些文件位于索引中:它们是各种
gitignore
文件。强调一下复数形式;虽然通常只有一个这样的文件在根目录,但每个目录中也可以有一个,并且它适用于该目录及其子目录。我称这些为作用域文件,因为它们只适用于其目录下的路径。 - 其他文件位于索引之外。它们是全局忽略文件(通常在
~/.config/git/ignore
)和特定于仓库的.git/info/exclude
。我称这些为绝对文件,因为它们适用于所有地方,但优先级较低。
再次,我们定义一个类来持有这些信息:一个包含绝对规则的列表,以及一个包含相对规则的字典(哈希表)。这个哈希表的键是目录,相对于工作树的根目录。
class GitIgnore(object):
absolute = None
scoped = None
def __init__(self, absolute, scoped):
self.absolute = absolute
self.scoped = scoped
最后,我们的函数将收集仓库中的所有 gitignore 规则,并返回一个 GitIgnore
对象。请注意,它是从索引中读取作用域文件,而不是从工作树中读取:只有已暂存的 .gitignore
文件才重要(还要记住:HEAD 已经 被暂存——暂存区是一个副本,而不是增量)。
def gitignore_read(repo):
ret = GitIgnore(absolute=list(), scoped=dict())
# 读取 .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()))
# 全局配置
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 文件
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
我们快完成了。为了将所有内容结合在一起,我们需要 check_ignore
函数,该函数将路径(相对于工作树的根目录)与一组规则进行匹配。这个函数的工作原理如下:
- 它首先尝试将这个路径与作用域规则匹配。从路径的最深父级开始,向上查找。也就是说,如果路径是
src/support/w32/legacy/sound.c~
,它将首先查找src/support/w32/legacy/.gitignore
中的规则,然后是src/support/w32/.gitignore
,接着是src/support/.gitignore
,依此类推,直到根目录的.gitignore
。 - 如果没有匹配的规则,它将继续查找绝对规则。
我们写三个小支持函数。一个是将路径与一组规则进行匹配,并返回最后一个匹配规则的结果。请注意,这不是一个真实的布尔函数,因为它有三种可能的返回值:True
、False
和 None
。如果没有匹配,则返回 None
,这样调用者就知道应该继续尝试更一般的忽略文件(例如,向上移动一级目录)。
def check_ignore1(rules, path):
result = None
for (pattern, value) in rules:
if fnmatch(path, pattern):
result = value
return result
另一个函数用于与作用域规则(各种 .gitignore
文件)的字典进行匹配。它从路径的目录开始,递归向上移动到父目录,直到测试到根目录。请注意,这个函数(以及接下来的两个函数)从不在给定的 .gitignore
文件内部中中断。即使某个规则匹配,它们仍会继续遍历该文件,因为另一个规则可能会否定之前的效果(规则按顺序处理,因此如果你想排除 *.c
但不想排除 generator.c
,一般规则必须在特定规则之前)。但是,只要在一个文件中至少有一个规则匹配,我们就丢弃剩余的文件,因为更一般的文件永远不会取消更具体的文件的效果(这就是为什么 check_ignore1
是三元的而不是布尔的原因)。
def check_ignore_scoped(rules, path):
parent = os.path.dirname(path)
while True:
if parent in rules:
result = check_ignore1(rules[parent], path)
if result is not None:
return result
if parent == "":
break
parent = os.path.dirname(parent)
return None
一个更简单的函数用于与绝对规则列表进行匹配。注意,我们将这些规则推送到列表中的顺序很重要(我们确实先读取了仓库规则,然后才是全局规则!)。
def check_ignore_absolute(rules, path):
parent = os.path.dirname(path)
for ruleset in rules:
result = check_ignore1(ruleset, path)
if result is not None:
return result
return False # 这在此时是一个合理的默认值。
最后,定义一个函数将它们绑定在一起。
def check_ignore(rules, path):
if os.path.isabs(path):
raise Exception("此函数要求路径相对于仓库的根目录")
result = check_ignore_scoped(rules.scoped, path)
if result is not None:
return result
return check_ignore_absolute(rules.absolute, path)
现在你可以调用 wyag check-ignore
。在它自己的源树中:
$ wyag check-ignore hello.el hello.elc hello.html wyag.zip wyag.tar
hello.elc
hello.html
wyag.zip
警告
这只是一个近似实现
这并不是一个完美的重新实现。特别是,通过仅使用目录名称的规则(例如 __pycache__
)来排除整个目录将不起作用,因为 fnmatch
需要模式为 __pycache__/**
。如果你真的想玩弄忽略规则,这可能是一个不错的起点。
8.5. status 命令
status
比 ls-files
更复杂,因为它需要将索引与 HEAD
和实际文件系统进行比较。你调用 git status
来知道自上一个提交以来哪些文件被添加、删除或修改,以及这些更改中哪些实际上是已暂存的,并将包含在下一个提交中。因此,status
实际上比较 HEAD
与暂存区,以及暂存区与工作树之间的差异。它的输出看起来像这样:
在分支 master 上
待提交的更改:
(使用 "git restore --staged <file>..." 来取消暂存)
修改: write-yourself-a-git.org
未暂存的更改:
(使用 "git add <file>..." 来更新将要提交的内容)
(使用 "git restore <file>..." 来放弃工作目录中的更改)
修改: write-yourself-a-git.org
未跟踪的文件:
(使用 "git add <file>..." 将其包含在将要提交的内容中)
org-html-themes/
wl-copy
我们将 status
实现分为三个部分:首先是活动分支或“分离的 HEAD”,然后是索引与工作树之间的差异(“未暂存的更改”),最后是 HEAD
与索引之间的差异(“待提交的更改”和“未跟踪的文件”)。
公共接口非常简单,我们的状态命令不接受任何参数:
argsp = argsubparsers.add_parser("status", help = "显示工作树状态。")
桥接函数按顺序调用三个组件函数:
def cmd_status(_):
repo = repo_find()
index = index_read(repo)
cmd_status_branch(repo)
cmd_status_head_index(repo, index)
print()
cmd_status_index_worktree(repo, index)
8.5.1. 查找活动分支
首先,我们需要知道我们是否在一个分支上,如果是的话是哪一个。我们通过查看 .git/HEAD
来实现。它应该包含一个十六进制 ID(指向一个提交,表示分离的 HEAD 状态),或者一个指向 refs/heads/
中某个内容的间接引用:即活动分支。我们返回其名称或 False
。
def branch_get_active(repo):
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
基于此,我们可以编写桥接调用的三个 cmd_status_*
函数中的第一个。这个函数打印活动分支的名称,或者分离 HEAD 的哈希值:
def cmd_status_branch(repo):
branch = branch_get_active(repo)
if branch:
print("在分支 {} 上。".format(branch))
else:
print("HEAD 在 {} 上分离".format(object_find(repo, "HEAD")))
8.5.2. 查找 HEAD 和索引之间的变化
状态输出的第二部分是“待提交的更改”,即暂存区与 HEAD 的不同之处。为此,我们首先需要读取 HEAD
树,并将其展平为一个包含完整路径作为键的字典(哈希映射),这样它就更接近于将路径与 blob 关联的(扁平)索引。然后我们只需比较它们并输出它们的差异。
首先,编写一个将树(递归的,记住)转换为(扁平的)字典的函数。由于树是递归的,因此该函数本身也是递归的——对此表示歉意 😃
def tree_to_dict(repo, ref, prefix=""):
ret = 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)
# 我们读取对象以提取其类型(这无谓地昂贵:我们可以直接将其作为文件打开并读取前几个字节)
is_subtree = leaf.mode.startswith(b'04')
# 根据类型,我们要么存储路径(如果是 blob,表示常规文件),要么递归(如果是另一个树,表示子目录)
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, index):
print("待提交的更改:")
head = tree_to_dict(repo, "HEAD")
for entry in index.entries:
if entry.name in head:
if head[entry.name] != entry.sha:
print(" 修改了:", entry.name)
del head[entry.name] # 删除该键
else:
print(" 添加了:", entry.name)
# 仍在 HEAD 中的键是我们在索引中未遇到的文件,因此这些文件已被删除。
for entry in head.keys():
print(" 已删除:", entry)
8.5.3. 查找索引与工作树之间的变化
def cmd_status_index_worktree(repo, index):
print("未暂存的更改:")
ignore = gitignore_read(repo)
gitdir_prefix = repo.gitdir + os.path.sep
all_files = list()
# 我们首先遍历文件系统
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)
# 现在我们遍历索引,并比较真实文件与缓存版本。
for entry in index.entries:
full_path = os.path.join(repo.worktree, entry.name)
# 该文件名在索引中
if not os.path.exists(full_path):
print(" 已删除:", entry.name)
else:
stat = os.stat(full_path)
# 比较元数据
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):
# 如果不同,进行深度比较。
# @FIXME 如果是指向目录的符号链接,这将崩溃。
with open(full_path, "rb") as fd:
new_sha = object_hash(fd, b"blob", None)
# 如果哈希相同,文件实际上是相同的。
same = entry.sha == new_sha
if not same:
print(" 修改了:", entry.name)
if entry.name in all_files:
all_files.remove(entry.name)
print()
print("未跟踪的文件:")
for f in all_files:
# @TODO 如果整个目录未跟踪,我们应该仅显示其名称而不包含内容。
if not check_ignore(ignore, f):
print(" ", f)
我们的状态函数完成了。它的输出应该类似于:
$ wyag status
在分支 main 上。
待提交的更改:
添加了:src/main.c
未暂存的更改:
修改了:build.py
已删除:README.org
未跟踪的文件:
src/cli.c
真实的 status
更加智能:例如,它可以检测重命名,而我们的版本则无法。还有一个显著的区别值得提及的是,git status
实际上会在文件元数据被修改但内容未被修改时,写回 索引。您可以通过我们的特殊 ls-files
查看这一点:
$ wyag ls-files --verbose
索引文件格式 v2,包含 1 个条目。
file
普通文件,权限:644
对应的 blob: f2f279981ce01b095c42ee7162aadf60185c8f67
创建时间:2023-07-18 18:26:15.771460869,修改时间:2023-07-18 18:26:15.771460869
...
$ touch file
$ git status > /dev/null
$ wyag ls-files --verbose
索引文件格式 v2,包含 1 个条目。
file
普通文件,权限:644
对应的 blob: f2f279981ce01b095c42ee7162aadf60185c8f67
创建时间:2023-07-18 18:26:41.421743098,修改时间:2023-07-18 18:26:41.421743098
...
注意,索引文件中的两个时间戳都被 git status
更新,以反映真实文件元数据的变化。