This video by John Williams 概述了用于计算 Git 提交哈希的数据。以下是视频截图:
在没有 Git 的情况下重新实现提交哈希
为了更深入地了解 Git 的这一方面,我重新实现了在 Rust 中生成 Git 提交哈希的步骤,而不使用 Git。它目前适用于在提交单个文件时获取散列。这里的答案有助于实现这一目标,谢谢。
这些是我们需要计算以达到 Git 提交哈希的单个数据:
- 文件的对象 ID,涉及使用 SHA-1 对文件内容进行哈希处理。在 Git 中,
hash-object 提供此 ID。
- 进入树对象的对象条目。在 Git 中,您可以通过
ls-tree 了解这些条目,但它们在树对象中的格式略有不同:[mode] [file name]\0[object ID]
-
树对象的散列,其形式为:
tree [size of object entries]\0[object entries]。在 Git 中,使用以下命令获取树哈希:git cat-file commit HEAD | head -n1
-
提交哈希通过对您使用
cat-file 看到的数据进行哈希处理。这包括树对象哈希和提交信息,例如作者、时间、提交消息和父提交哈希(如果不是第一次提交)。
每一步都依赖于前一步。让我们从第一个开始。
获取文件的对象ID
第一步是重新实现Git的hash-object,如git hash-object your_file。
我们通过连接和散列这些数据从我们的文件创建对象散列:
- 开头的字符串“blob”(注意尾随空格),后跟
- 文件中的字节数,后跟
- 一个空字节,在
printf和Rust中用\0表示,后跟
- 文件内容。
在 Bash 中:
file_name="your_file";
printf "blob $(wc -c < "$file_name")\0$(cat "$file_name")" | sha1sum
在锈中:
// Get the object ID
fn git_hash_object(file_content: &[u8]) -> Vec<u8> {
let file_size = file_content.len().to_string();
let hash_input: Vec<u8> = vec![
"blob ".as_bytes(),
file_size.as_bytes(),
b"\0",
file_content,
]
// Flatten the Vec<&[u8]> to Vec<u8> with concat
.concat();
to_sha1(&hash_input)
}
我在to_sha1 中使用crate sha1 0.6.0 版:
fn to_sha1(hash_me: &[u8]) -> Vec<u8> {
let mut m = Sha1::new();
m.update(hash_me);
m.digest().bytes().to_vec()
}
获取文件的对象入口
对象条目是 Git 的 tree object 的一部分。树对象代表文件和目录。
文件的对象条目具有这种形式:[mode] [file name]\0[object ID]
我们假设该文件是一个常规的、不可执行的文件,它在 Git 中转换为模式 100644。有关模式的更多信息,请参阅this。
这个Rust函数将上一个函数git_hash_object的结果作为参数object_id:
fn object_entry(file_name: &FileName, object_id: &[u8]) -> Vec<u8> {
// It's a regular, non-executable file
let mode = "100644";
// [mode] [file name]\0[object ID]
let object_entry: Vec<u8> = vec![
mode.as_bytes(),
b" ",
file_name.as_bytes(),
b"\0",
object_id,
]
.concat();
object_entry
}
FileName 是String 的新类型,以避免混淆参数,没什么花哨的:
pub struct FileName(pub String);
impl std::fmt::Display for FileName {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl FileName {
fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
我尝试在 Bash 中编写 object_entry 的等价物,但 Bash 变量 cannot contain null bytes。可能有一些方法可以绕过这个限制,但我现在决定,如果我不能在 Bash 中使用变量,那么代码将变得非常难以理解。欢迎编辑提供可读的 Bash 等效项。
获取树对象哈希
如上所述,树对象代表 Git 中的文件和目录。您可以通过运行查看树对象的哈希值,例如,git cat-file commit HEAD | head -n1。
树对象有这种形式:tree [size of object entries]\0[object entries]
在我们的例子中,我们只有一个object_entry,在上一步中计算出来:
fn tree_object_hash(object_entry: &[u8]) -> String {
let object_entry_size = object_entry.len().to_string();
let tree_object: Vec<u8> = vec![
"tree ".as_bytes(),
object_entry_size.as_bytes(),
b"\0",
object_entry,
]
.concat();
to_hex_str(&to_sha1(&tree_object))
}
其中to_hex_str定义为:
// Converts bytes to their hexadecimal representation.
fn to_hex_str(bytes: &[u8]) -> String {
bytes.iter().map(|x| format!("{:02x}", x)).collect()
}
在 Git 存储库中,您可以使用 ls-tree 查看树对象的内容。例如,运行git ls-tree HEAD 将产生如下行:
100644 blob b8c0d74ef5ccd3dab583add7b3f5367efe4bf823 your_file
虽然这些行包含对象条目的数据(模式、对象 ID 和文件名),但它们的顺序不同,包括制表符以及输入到对象 ID,而不是对象条目。对象条目具有这种形式:[mode] [file name]\0[object ID]
获取提交哈希
最后一步创建提交哈希。
我们使用 SHA-1 散列的数据包括:
- 上一步的树对象哈希。
- 如果提交不是 repo 中的第一个提交,则父提交的哈希。
- 作者姓名和创作日期。
- 提交者名称和提交日期。
- 提交消息。
您可以使用git cat-file commit HEAD 查看所有这些数据,例如:
tree a76b2df314b47956268b0c39c88a3b2365fb87eb
parent 9881a96ab93a3493c4f5002f17b4a1ba3308b58b
author Matthias Braun <m.braun@example.com> 1625338354 +0200
committer Matthias Braun <m.braun@example.com> 1625338354 +0200
Second commit (that's the commit message)
您可能已经猜到1625338354 是一个时间戳,即自Unix epoch 以来的秒数。您可以从git log的日期和时间格式转换,例如“Wed Jun 23 18:02:18 2021”,用date:
date --date='Wed Jun 23 18:02:18 2021' +"%s"
在本例中,时区表示为+0200。
根据cat-file 的输出,您可以使用此 Bash 命令(使用git cat-file,因此无需重新实现)创建 Git 提交哈希:
cat_file_output=$(git cat-file commit HEAD);
printf "commit $(wc -c <<< "$cat_file_output")\0$cat_file_output\n" | sha1sum
Bash 命令说明了——类似于前面的步骤——我们散列的是:
- 此步骤中的前导字符串“commit”,后跟
- 一堆数据的大小。这是上面详述的
cat-file 的输出。其次是
- 一个空字节,后跟
- 数据本身(
cat-file 的输出),末尾有换行符。
如果您保持得分:创建 Git 提交哈希涉及使用 SHA-1 至少 3 次。
以下是用于创建 Git 提交哈希的 Rust 函数。它使用上一步中生成的tree_object_hash 和一个结构CommitMetaData,其中包含您在调用git cat-file commit HEAD 时看到的其余数据。该函数还负责提交是否有父提交。
fn commit_hash(commit: &CommitMetaData, tree_object_hash: &str) -> Vec<u8> {
let author_line =
commit.author_name_and_email.to_owned() + " " +
commit.author_timestamp_and_timezone;
let committer_line =
commit.committer_name_and_email.to_owned() + " " +
commit.committer_timestamp_and_timezone;
// If it's the first commit, which has no parent,
// the line starting with "parent" is omitted
let parent_commit_line = match &commit.parent_commit_hash {
Some(parent_commit_hash) => "\nparent ".to_owned() + parent_commit_hash,
None => "".to_string(),
};
let git_cat_file_str = format!(
"tree {}{}\nauthor {}\ncommitter {}\n\n{}\n",
tree_object_hash,
parent_commit_line,
author_line,
committer_line,
commit.commit_message
);
let git_cat_file_len = git_cat_file_str.len().to_string();
let commit_object: Vec<u8> = vec![
"commit ".as_bytes(),
git_cat_file_len.as_bytes(),
b"\0",
git_cat_file_str.as_bytes(),
].concat();
// Return the Git commit hash
to_sha1(&commit_object)
}
这里是CommitMetaData:
#[derive(Debug, Copy, Clone)]
pub struct CommitMetaData<'a> {
pub(crate) author_name_and_email: &'a str,
pub(crate) author_timestamp_and_timezone: &'a str,
pub(crate) committer_name_and_email: &'a str,
pub(crate) committer_timestamp_and_timezone: &'a str,
pub(crate) commit_message: &'a str,
// All commits after the first one have a parent commit
pub(crate) parent_commit_hash: Option<&'a str>,
}
这个函数会创建CommitMetaDataCommitMetaData,这里的作者和提交者信息是一致的,这样我们以后运行程序时会很方便:
pub fn simple_commit<'a>(
author_name_and_email: &'a str,
author_timestamp_and_timezone: &'a str,
commit_message: &'a str,
parent_commit_hash: Option<&'a str>,
) -> CommitMetaData<'a> {
CommitMetaData {
author_name_and_email,
author_timestamp_and_timezone,
committer_name_and_email: author_name_and_email,
committer_timestamp_and_timezone: author_timestamp_and_timezone,
commit_message,
parent_commit_hash,
}
}
把它们放在一起
作为总结和提醒,创建 Git 提交哈希包括获取:
- 文件的对象 ID,涉及使用 SHA-1 对文件内容进行哈希处理。在 Git 中,
hash-object 提供此 ID。
- 进入树对象的对象条目。在 Git 中,您可以通过
ls-tree 了解这些条目,但它们在树对象中的格式略有不同:[mode] [file name]\0[object ID]
-
树对象的散列,其形式为:
tree [size of object entries]\0[object entries]。在 Git 中,使用以下命令获取树哈希:git cat-file commit HEAD | head -n1
-
提交哈希通过对您使用
cat-file 看到的数据进行哈希处理。这包括树对象哈希和提交信息,例如作者、时间、提交消息和父提交哈希(如果不是第一次提交)。
在锈中:
pub fn get_commit_hash(
file_name: &FileName,
file_content: &[u8],
commit: &CommitMetaData,
) -> String {
let file_object_id = git_hash_object(file_content);
let object_entry = object_entry(file_name, &file_object_id);
let tree_object_hash = tree_object_hash(&object_entry);
let commit_hash = commit_hash(commit, &tree_object_hash);
to_hex_str(&commit_hash)
}
使用上述函数,您可以在 Rust 中创建文件的 Git 提交哈希,无需 Git:
use std::fs::File;
use std::io;
use std::io::prelude::*;
fn main() -> io::Result<()> {
let file_name = FileName("your_file".to_string());
let file_content = read_all_bytes(&file_name)?;
let first_commit = simple_commit(
"Firstname Lastname <test@example.com>",
// Timestamp calculated using: date --date='Wed Jun 23 18:02:18 2021' +"%s"
"1625338354 +0200",
"Message of first commit",
// No parent commit hash since this is the first commit
None,
);
let first_commit_hash = get_commit_hash(&file_name, &file_content, &first_commit);
Ok(println!("Git commit hash: {} ", first_commit_hash))
}
fn read_all_bytes(file_name: &FileName) -> io::Result<Vec<u8>> {
let mut f = File::open(file_name.to_string())?;
let mut file_content = Vec::new();
f.read_to_end(&mut file_content)?;
Ok(file_content)
}
要创建第二次提交的哈希,您需要将第一次提交的哈希放入第二次提交的CommitMetaData:
let second_commit = simple_commit(
"Firstname Lastname <test@example.com>",
"1625388354 +0200",
"Message of second commit",
// The first commit is the parent of the second commit
Some(first_commit_hash),
);
除了此处的其他答案及其链接之外,这些是创建我有限的重新实现的一些有用资源: