Git哈希值是如何计算的?

66

我正在尝试理解Git如何计算引用哈希值。

$ git ls-remote https://github.com/git/git  

....
29932f3915935d773dc8d52c292cadd81c81071d    refs/tags/v2.4.2
9eabf5b536662000f79978c4d1b6e4eff5c8d785    refs/tags/v2.4.2^{}
....

将repo克隆到本地。按sha检查refs/tags/v2.4.2^{}引用

$ git cat-file -p 9eabf5b536662000f79978c4d1b6e4eff5c8d785 

tree 655a20f99af32926cbf6d8fab092506ddd70e49c
parent df08eb357dd7f432c3dcbe0ef4b3212a38b4aeff
author Junio C Hamano <gitster@pobox.com> 1432673399 -0700
committer Junio C Hamano <gitster@pobox.com> 1432673399 -0700

Git 2.4.2

Signed-off-by: Junio C Hamano <gitster@pobox.com>

复制解压后的内容以便我们可以哈希它。(据我所知, Git在进行哈希时使用未压缩的版本)

git cat-file -p 9eabf5b536662000f79978c4d1b6e4eff5c8d785 > fi

让我们使用Git自带的哈希命令SHA-1加密内容

git hash-object fi
3cf741bbdbcdeed65e5371912742e854a035e665
为什么输出不是[9e]abf5b536662000f79978c4d1b6e4eff5c8d785?我理解前两个字符(9e)是十六进制长度。我应该如何对fi的内容进行哈希,以便获得Git引用abf5b536662000f79978c4d1b6e4eff5c8d785
我应如何哈希fi中的内容以获取Git引用abf5b536662000f79978c4d1b6e4eff5c8d785,为什么输出结果不是[9e]abf5b536662000f79978c4d1b6e4eff5c8d785,我知道前两个字符9e是长度的十六进制表示。

1
(1)git hash-object 是用来添加文件的,而不是其他类型的对象。显然,类型会以某种方式附加到散列内容上。我敢打赌9e不是长度,整行都是一个哈希值,因为SHA1算法返回的就是这样的结果。 - max630
2
SHA=9eabf5b536662000f79978c4d1b6e4eff5c8d785; git cat-file -p $SHA | git hash-object -t $(git cat-file -t $SHA) --stdin. Read: You need git hash-object -t commit fi - Tino
3个回答

35

正如在"git commit sha1是如何形成的"中所述,公式为:

(printf "<type> %s\0" $(git cat-file <type> <ref> | wc -c); git cat-file <type> <ref>)|sha1sum

对于提交 9eabf5b536662000f79978c4d1b6e4eff5c8d785(它是v2.4.2^{},并且引用了一棵树)的情况:

(printf "commit %s\0" $(git cat-file commit 9eabf5b536662000f79978c4d1b6e4eff5c8d785 | wc -c); git cat-file commit 9eabf5b536662000f79978c4d1b6e4eff5c8d785 )|sha1sum

这将给出9eabf5b536662000f79978c4d1b6e4eff5c8d785。

同样的结果可以通过以下方式得到:

(printf "commit %s\0" $(git cat-file commit v2.4.2{} | wc -c); git cat-file commit v2.4.2{})|sha1sum

(仍然是9eabf5b536662000f79978c4d1b6e4eff5c8d785)

同样,计算标签v2.4.2的SHA1为:

(printf "tag %s\0" $(git cat-file tag v2.4.2 | wc -c); git cat-file tag v2.4.2)|sha1sum

这将产生29932f3915935d773dc8d52c292cadd81c81071d。


我不确定为什么,但我得到了不同的数据 $ (printf "tree %s\0" $(git cat-file tree 9eabf5b536662000f79978c4d1b6e4eff5c8d785 | wc -c); git cat-file tree 9eabf5b536662000f79978c4d1b6e4eff5c8d785 )|sha1sum 655a20f99af32926cbf6d8fab092506ddd70e49c - The user with no hat
你混淆了提交和树:请使用相同的类型。 - VonC
然后你必须获得相同的sha1。在使用取消引用标记时是否有效?v2.4.2{} - VonC
我认为问题是由于使用了<type>和tree。这个命令可以正常工作(使用-pretty和commit)。有任何想法为什么它在使用commit时可以工作,如果它是一个'tree'?(printf "commit %s\0" $(git cat-file -p 9eabf5b536662000f79978c4d1b6e4eff5c8d785 | wc -c); git cat-file -p 9eabf5b536662000f79978c4d1b6e4eff5c8d785 )|sha1sum 9eabf5b536662000f79978c4d1b6e4eff5c8d785 - The user with no hat
1
@Theuserwithnohat 我的错:我在我的代码库中测试了它,并且确实使用提交 (在您的情况下,它引用了树 655a20f99af32926cbf6d8fab092506ddd70e49c) 工作。我已经相应地更新了答案。 - VonC

29

这个视频由John Williams制作,概述了计算Git提交哈希值所需的数据。以下是视频中的截图:

Git tree

不使用Git重新实现提交哈希

为了更深入地理解Git的这个方面,我在Rust中重新实现了生成Git提交哈希的步骤,而不使用Git。目前它可以在“提交单个文件”时获取哈希值。这里的答案对我实现这一点非常有帮助,谢谢。

这个答案的源代码可以在这里找到。使用cargo run来执行它。

以下是我们需要计算的各个数据部分,以得到一个Git提交哈希:

  1. 文件的对象ID,涉及使用SHA-1对文件内容进行哈希。在Git中,hash-object提供了这个ID。
  2. 进入树对象的对象条目。在Git中,你可以通过ls-tree来了解这些条目,但它们在树对象中的格式略有不同:[模式] [文件名]\0[对象ID]
  3. 树对象的哈希值的形式为:tree [对象条目的大小]\0[对象条目]。在Git中,可以使用以下命令获取树的哈希值:git cat-file commit HEAD | head -n1
  4. 通过对你看到的数据进行哈希,得到提交哈希值。这包括树对象的哈希值以及提交信息,如作者、时间、提交消息,以及父提交哈希(如果不是第一个提交)。
每一步都依赖于前一步。让我们从第一步开始。
获取文件的对象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

在Rust中:
// Get the object ID
fn git_hash_object(file_content: &[u8]) -> Vec<u8> {
    let file_size = file_content.len().to_string();
    let hash_input = [
        "blob ".as_bytes(),
        file_size.as_bytes(),
        b"\0",
        file_content,
    ]
    .concat();
    to_sha1(&hash_input)
}

我在to_sha1中使用crate sha1版本0.10.5。
fn to_sha1(hash_me: &[u8]) -> Vec<u8> {
    use sha1::{Digest, Sha1};

    let mut hasher = Sha1::new();
    hasher.update(hash_me);
    hasher.finalize().to_vec()
}

获取文件的对象条目

对象条目是Git的树对象的一部分。树对象代表文件和目录。

文件的对象条目具有以下形式:[模式] [文件名]\0[对象ID]

我们假设该文件是一个普通的非可执行文件,在Git中对应的模式是100644。请参阅此处了解更多关于模式的信息。

这个Rust函数将前一个函数git_hash_object的结果作为参数object_id传入:

fn object_entry(file_name: &str, object_id: &[u8]) -> Vec<u8> {
    // It's a regular, non-executable file
    let mode = "100644";

    // [mode] [file name]\0[object ID]
    let object_entry = [
        mode.as_bytes(),
        b" ",
        file_name.as_bytes(),
        b"\0",
        object_id,
    ]
    .concat();

    object_entry
}

我试图在Bash中写出与object_entry等效的代码,但是Bash变量不能包含空字节。可能有办法绕过这个限制,但是我决定暂时如果在Bash中不能使用变量,那么代码会变得非常难以理解。欢迎提供一个可读的Bash等效代码的修改。

获取树对象哈希值

如上所述,树对象代表Git中的文件和目录。您可以通过运行例如git cat-file commit HEAD | head -n1来查看树对象的哈希值。

树对象的形式为:tree [对象条目的大小]\0[对象条目]

在我们的情况下,我们只有一个单独的object_entry,在前一步中计算出来:

fn tree_object_hash(object_entry: &[u8]) -> String {
    let object_entry_size = object_entry.len().to_string();

    let tree_object = [
        "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(|byte| format!("{byte:02x}")).collect()
}

在 Git 存储库中,您可以使用 ls-tree 查看树对象的内容。例如,运行 git ls-tree HEAD 将生成以下类似的行:
100644 blob b8c0d74ef5ccd3dab583add7b3f5367efe4bf823    your_file

虽然这些行包含了一个对象条目的数据(模式、对象ID和文件名),但它们的顺序不同,并且还包括一个制表符以及作为对象ID输入的字符串"blob"。对象条目的形式如下:[模式] [文件名]\0[对象ID]

获取提交哈希值

最后一步是创建提交哈希值。

我们使用SHA-1进行哈希的数据包括:

  • 来自上一步的树对象哈希。
  • 如果提交不是仓库中的第一个提交,则包括父提交的哈希。
  • 作者姓名和作者日期。
  • 提交者姓名和提交日期。
  • 提交消息。

你可以通过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纪元以来的秒数。你可以通过dategit log中的日期和时间格式(如"Sat Jul 3 20:52:34 2021")转换为Unix纪元秒数:
date --date='Sat Jul 3 20:52:34 2021' +"%s"

在这个例子中,时区被表示为+0200

根据cat-file的输出,您可以使用以下Bash命令创建Git提交哈希(该命令使用git cat-file,因此不需要重新实现):

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 三次。

下面是用于创建 Git 提交哈希的 Rust 函数。它使用在上一步中生成的 tree_object_hash 和一个包含调用 git cat-file commit HEAD 时看到的其余数据的结构体 CommitMetaData。该函数还会处理提交是否有父提交。

fn commit_hash(commit: &CommitMetaData, tree_object_hash: &str) -> Vec<u8> {
    let author_line = format!(
        "{} {}",
        commit.author_name_and_email, commit.author_timestamp_and_timezone
    );
    let committer_line = format!(
        "{} {}",
        commit.committer_name_and_email, 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) => format!("\nparent {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 = [
        "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>,
}

这个函数创建CommitMetaData,其中作者和提交者的信息是相同的,在以后运行程序时将非常方便:

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提交哈希包括以下步骤:

  1. 文件的对象 ID,它涉及使用SHA-1对文件内容进行散列。在Git中,hash-object提供了该ID。
  2. 进入树对象的对象条目。在Git中,你可以通过ls-tree获取这些条目的一个概念,但它们在树对象中的格式略有不同:[模式] [文件名]\0[对象ID]
  3. 树对象的散列值,其形式为:tree [对象条目的大小]\0[对象条目]。在Git中,使用以下命令获取树对象的散列值:git cat-file commit HEAD | head -n1
  4. 通过对看到的数据进行散列来获得提交散列值,其中包括树对象的散列值以及作者、时间、提交消息和父提交散列值(如果它不是第一个提交)等提交信息。使用cat-file进行散列。
在Rust中:
pub fn get_commit_hash(
    file_name: &str,
    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, io};

fn main() -> io::Result<()> {
    let file_name = "your_file";
    let file_content = fs::read(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"
        "1624464138 +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}"))
}

要创建第二个提交的哈希值,你需要将第一个提交的哈希值放入第二个提交的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),
);

除了这里的其他答案和链接之外,以下是我在创建有限重新实现时使用的一些有用资源:
  • JavaScript中git hash-object重新实现
  • Git树对象的格式,如果我想使我的重新实现更完整,这将是我下一个查找的地方:处理涉及多个文件的提交。

你提到树的大小是通过对象条目的大小来计算的。你的意思是指所有对象字符的总和吗?例如,如果一棵树有两个对象,头部是blob 10\0,那么树的大小就是20吗? - undefined
你需要对每个对象条目的字节进行求和,而不是文件大小。请注意,我没有测试过这个,我只为单个对象条目实现了它。"如果一棵树有两个对象,其头部是blob 10\0,那么树的大小会是20吗?"我不这么认为,因为每个对象条目的格式是[模式] [文件名]\0[对象ID],而在"blob"之后的数字是指文件大小,它是用于计算对象ID的数据的一部分。 - undefined

11
这里有些混淆。Git使用不同类型的对象:blob、tree和commit。下面是相应的命令:
git cat-file -t <hash>

给定哈希值,告诉你对象的类型。因此,在您的示例中,哈希值9eabf5b536662000f79978c4d1b6e4eff5c8d785对应于提交对象。

现在,正如您自己发现的那样,运行以下命令:

git cat-file -p 9eabf5b536662000f79978c4d1b6e4eff5c8d785

将根据对象的类型(在此示例中为提交)提供对象的内容。
但是,这个:
git hash-object fi

这个命令计算一个blob的哈希值,该blob的内容是前一个命令的输出(在您的示例中),但它也可以是任何其他内容(如“hello world!”)。试试这个:

echo "blob 277\0$(cat fi)" | shasum

输出与前一个命令相同。这基本上是 Git 如何哈希 blob。因此,通过哈希 fi,您正在生成一个 blob 对象。但正如我们所见,9eabf5b536662000f79978c4d1b6e4eff5c8d785 是提交而不是 blob。因此,您不能像原样哈希 fi 以获取相同的哈希值。
提交的哈希基于其他几个信息,使其唯一(例如提交者、作者、日期等)。以下文章告诉您提交哈希由什么组成: Git 提交解剖 因此,您可以提供文章中指定的所有数据,并使用与原始提交中使用的完全相同的值来获得相同的哈希。
这也可能会有所帮助: 从底层开始学习 Git

2
“echo "blob 277\0$(cat fi)" | shasum” 对我来说产生了与“git hash-object fi”不同的结果,原因有两个:首先,我不知道277是指“fi”的大小,而我的特定“fi”的大小不等于277。其次,“echo”的GNU coreutils版本添加了一个换行符,并且没有转义“\0”以表示NUL字节(“echo -en”可以解决这个问题)。以下命令产生与“git hash-object fi”相同的结果:“printf“ blob $(wc -c <fi)\0 $(cat fi)”| sha1sum”。 - Matthias Braun
因此,它是“blob ${filesizeinbytes}\0${filecontent}”。这个命令在Windows下的git bash中运行良好:stat --printf="blob %s\0" $FILENAME | cat - $FILENAME | sha1sum - Martin Scharrer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接