blame: allow --contents to work with non-HEAD commit

The --contents option can be used with git blame to blame the file as if it had the contents from the specified file. This is akin to copying the contents into the working tree and then running git blame. This option has been supported since 1cfe77333f27 ("git-blame: no rev means start from the working tree file.") The --contents option always blames the file as if it was based on the current HEAD commit. If you try to pass a revision while using --contents, you get the following error: fatal: cannot use --contents with final commit object name This is because the blame process generates a fake working tree commit which always uses the HEAD object as its sole parent. Enhance fake_working_tree_commit to take the object ID to use for the parent instead of always using the HEAD object. Then, always generate a fake commit when we have contents provided, even if we have a final object. Remove the check to disallow --contents and a final revision. Note that the behavior of generating a fake working commit is still skipped when a revision is provided but --contents is not provided. Generating such a commit in that case would combine the currently checked out file contents with the provided revision, which breaks normal blame behavior and produces unexpected results. This enables use of --contents with an arbitrary revision, rather than forcing the use of the local HEAD commit. This makes the --contents option significantly more flexible, as it is no longer required to check out the working tree to the desired commit before using --contents. Reword the documentation so that its clear that --contents can be used with <rev>. Add tests for the --contents option to the annotate-tests.sh test script. Signed-off-by: Jacob Keller <jacob.keller@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Jacob Keller committed Mar 24, 2023 at 10:08 UTC 1a3119ed06c8fbb1c00a6aa3615299252575abab
4 files changed +46 -20
Documentation/blame-options.txt
+5 -5
@@ -64,11 +64,11 @@ include::line-range-format.txt[]
64 manual page.
65
66 --contents <file>::
67 - When <rev> is not specified, the command annotates the
68 - changes starting backwards from the working tree copy.
69 - This flag makes the command pretend as if the working
70 - tree copy has the contents of the named file (specify
71 - `-` to make the command read from the standard input).
67 + Pretend the file being annotated has a commit with the
68 + contents from the named file and a parent of <rev>,
69 + defaulting to HEAD when no <rev> is specified. You may
70 + specify '-' to make the command read from the standard
71 + input for the file contents.
72
73 --date <format>::
74 Specifies the format used to output dates. If --date is not
Documentation/git-blame.txt
+1 -1
@@ -12,7 +12,7 @@ SYNOPSIS
12 [-L <range>] [-S <revs-file>] [-M] [-C] [-C] [-C] [--since=<date>]
13 [--ignore-rev <rev>] [--ignore-revs-file <file>]
14 [--color-lines] [--color-by-age] [--progress] [--abbrev=<n>]
15 - [<rev> | --contents <file> | --reverse <rev>..<rev>] [--] <file>
15 + [ --contents <file> ] [<rev> | --reverse <rev>..<rev>] [--] <file>
16
17 DESCRIPTION
18 -----------
blame.c
+26 -14
@@ -176,12 +176,12 @@ static void set_commit_buffer_from_strbuf(struct repository *r,
176 static struct commit *fake_working_tree_commit(struct repository *r,
177 struct diff_options *opt,
178 const char *path,
179 - const char *contents_from)
179 + const char *contents_from,
180 + struct object_id *oid)
181 {
182 struct commit *commit;
183 struct blame_origin *origin;
184 struct commit_list **parent_tail, *parent;
184 - struct object_id head_oid;
185 struct strbuf buf = STRBUF_INIT;
186 const char *ident;
187 time_t now;
@@ -197,10 +197,7 @@ static struct commit *fake_working_tree_commit(struct repository *r,
197 commit->date = now;
198 parent_tail = &commit->parents;
199
200 - if (!resolve_ref_unsafe("HEAD", RESOLVE_REF_READING, &head_oid, NULL))
201 - die("no such ref: HEAD");
202 -
203 - parent_tail = append_parent(r, parent_tail, &head_oid);
200 + parent_tail = append_parent(r, parent_tail, oid);
201 append_merge_parents(r, parent_tail);
202 verify_working_tree_path(r, commit, path);
203
@@ -2771,22 +2768,37 @@ void setup_scoreboard(struct blame_scoreboard *sb,
2768 sb->commits.compare = compare_commits_by_reverse_commit_date;
2769 }
2770
2774 - if (sb->final && sb->contents_from)
2775 - die(_("cannot use --contents with final commit object name"));
2776 -
2771 if (sb->reverse && sb->revs->first_parent_only)
2772 sb->revs->children.name = NULL;
2773
2780 - if (!sb->final) {
2774 + if (sb->contents_from || !sb->final) {
2775 + struct object_id head_oid, *parent_oid;
2776 +
2777 /*
2782 - * "--not A B -- path" without anything positive;
2783 - * do not default to HEAD, but use the working tree
2784 - * or "--contents".
2778 + * Build a fake commit at the top of the history, when
2779 + * (1) "git blame [^A] --path", i.e. with no positive end
2780 + * of the history range, in which case we build such
2781 + * a fake commit on top of the HEAD to blame in-tree
2782 + * modifications.
2783 + * (2) "git blame --contents=file [A] -- path", with or
2784 + * without positive end of the history range but with
2785 + * --contents, in which case we pretend that there is
2786 + * a fake commit on top of the positive end (defaulting to
2787 + * HEAD) that has the given contents in the path.
2788 */
2789 + if (sb->final) {
2790 + parent_oid = &sb->final->object.oid;
2791 + } else {
2792 + if (!resolve_ref_unsafe("HEAD", RESOLVE_REF_READING, &head_oid, NULL))
2793 + die("no such ref: HEAD");
2794 + parent_oid = &head_oid;
2795 + }
2796 +
2797 setup_work_tree();
2798 sb->final = fake_working_tree_commit(sb->repo,
2799 &sb->revs->diffopt,
2789 - sb->path, sb->contents_from);
2800 + sb->path, sb->contents_from,
2801 + parent_oid);
2802 add_pending_object(sb->revs, &(sb->final->object), ":");
2803 }
2804
t/annotate-tests.sh
+14
@@ -72,6 +72,16 @@ test_expect_success 'blame 1 author' '
72 check_count A 2
73 '
74
75 +test_expect_success 'blame with --contents' '
76 + check_count --contents=file A 2
77 +'
78 +
79 +test_expect_success 'blame with --contents changed' '
80 + echo "1A quick brown fox jumps over the" >contents &&
81 + echo "another lazy dog" >>contents &&
82 + check_count --contents=contents A 1 "Not Committed Yet" 1
83 +'
84 +
85 test_expect_success 'blame in a bare repo without starting commit' '
86 git clone --bare . bare.git &&
87 (
@@ -98,6 +108,10 @@ test_expect_success 'blame 2 authors' '
108 check_count A 2 B 2
109 '
110
111 +test_expect_success 'blame with --contents and revision' '
112 + check_count -h testTag --contents=file A 2 "Not Committed Yet" 2
113 +'
114 +
115 test_expect_success 'setup B1 lines (branch1)' '
116 git checkout -b branch1 main &&
117 echo "3A slow green fox jumps into the" >>file &&