Merge branch 'mv/log-follow-mergy' into seen

"git log --follow" has been updated to handle non-linear history, in
which the path being tracked gets renamed differently in multiple
history lines, better.

* mv/log-follow-mergy:
  log: improve --follow following renames for non-linear history
This commit is contained in:
Junio C Hamano
2026-06-15 10:38:21 -07:00
7 changed files with 254 additions and 2 deletions

View File

@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.
`log.follow`::
If `true`, `git log` will act as if the `--follow` option was used when
a single <path> is given. This has the same limitations as `--follow`,
i.e. it cannot be used to follow multiple files and does not work well
on non-linear history.
i.e. it cannot be used to follow multiple files.
`log.graphColors`::
A list of colors, separated by commas, that can be used to draw

View File

@@ -3,6 +3,7 @@
#include "git-compat-util.h"
#include "commit-reach.h"
#include "commit-slab.h"
#include "config.h"
#include "diff.h"
#include "diffcore.h"
@@ -1089,6 +1090,96 @@ static int do_remerge_diff(struct rev_info *opt,
return !opt->loginfo;
}
/* Per-commit path storage for --follow across merges */
define_commit_slab(follow_pathspec_slab, char *);
static const char *pathspec_single_path(const struct pathspec *ps)
{
if (ps->nr != 1)
return NULL;
return ps->items[0].match;
}
static void set_pathspec_to_single_path(struct pathspec *ps, const char *path)
{
const char *paths[2] = { path, NULL };
clear_pathspec(ps);
parse_pathspec(ps,
PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
PATHSPEC_LITERAL_PATH, "", paths);
}
static void remember_follow_pathspec(struct rev_info *opt,
struct commit *c, const char *path)
{
char **slot;
if (!path)
return;
if (!opt->follow_pathspec_slab) {
opt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));
init_follow_pathspec_slab(opt->follow_pathspec_slab);
}
slot = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);
if (*slot && !strcmp(*slot, path))
return;
free(*slot);
*slot = xstrdup(path);
}
static const char *recall_follow_pathspec(struct rev_info *opt,
struct commit *c)
{
char **slot;
if (!opt->follow_pathspec_slab)
return NULL;
slot = follow_pathspec_slab_peek(opt->follow_pathspec_slab, c);
return slot ? *slot : NULL;
}
static void free_follow_pathspec_slot(char **slot)
{
FREE_AND_NULL(*slot);
}
void release_follow_pathspec_slab(struct rev_info *opt)
{
if (!opt->follow_pathspec_slab)
return;
deep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,
free_follow_pathspec_slot);
FREE_AND_NULL(opt->follow_pathspec_slab);
}
/* Compute a path to follow in parent, if there is one */
static void propagate_follow_pathspec_to_parent(struct rev_info *opt,
struct commit *commit,
struct commit *parent)
{
struct diff_options diff_opts;
const char *path;
parse_commit_or_die(parent);
repo_diff_setup(opt->diffopt.repo, &diff_opts);
copy_pathspec(&diff_opts.pathspec, &opt->diffopt.pathspec);
diff_opts.flags.recursive = 1;
diff_opts.flags.follow_renames = 1;
diff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;
diff_setup_done(&diff_opts);
diff_tree_oid(get_commit_tree_oid(parent),
get_commit_tree_oid(commit),
"", &diff_opts);
path = pathspec_single_path(&diff_opts.pathspec);
if (path)
remember_follow_pathspec(opt, parent, path);
diff_queue_clear(&diff_queued_diff);
diff_free(&diff_opts);
}
/*
* Show the diff of a commit.
*
@@ -1185,6 +1276,16 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)
opt->loginfo = &log;
opt->diffopt.no_free = 1;
/* Any recorded path for this commit? If so, restore it */
if (opt->diffopt.flags.follow_renames) {
const char *stored = recall_follow_pathspec(opt, commit);
if (stored) {
const char *current = pathspec_single_path(&opt->diffopt.pathspec);
if (!current || strcmp(current, stored))
set_pathspec_to_single_path(&opt->diffopt.pathspec, stored);
}
}
if (opt->track_linear && !opt->linear && !opt->reverse_output_stage)
fprintf(opt->diffopt.file, "\n%s\n", opt->break_bar);
shown = log_tree_diff(opt, commit, &log);
@@ -1197,6 +1298,21 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)
fprintf(opt->diffopt.file, "\n%s\n", opt->break_bar);
if (shown)
show_diff_of_diff(opt);
/* Record what path each parent of this commit should use */
if (opt->diffopt.flags.follow_renames) {
struct commit_list *parents = get_saved_parents(opt, commit);
if (parents && parents->next) {
struct commit_list *p;
for (p = parents; p; p = p->next)
propagate_follow_pathspec_to_parent(opt, commit,
p->item);
} else if (parents) {
remember_follow_pathspec(opt, parents->item,
pathspec_single_path(&opt->diffopt.pathspec));
}
}
opt->loginfo = NULL;
maybe_flush_or_die(opt->diffopt.file, "stdout");
opt->diffopt.no_free = no_free;

View File

@@ -26,6 +26,7 @@ struct decoration_options {
int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);
int log_tree_diff_flush(struct rev_info *);
int log_tree_commit(struct rev_info *, struct commit *);
void release_follow_pathspec_slab(struct rev_info *);
void show_log(struct rev_info *opt);
void format_decorations(struct strbuf *sb, const struct commit *commit,
enum git_colorbool use_color, const struct decoration_options *opts);

View File

@@ -26,6 +26,7 @@
#include "decorate.h"
#include "string-list.h"
#include "line-log.h"
#include "log-tree.h"
#include "mailmap.h"
#include "commit-slab.h"
#include "cache-tree.h"
@@ -3302,6 +3303,7 @@ void release_revisions(struct rev_info *revs)
line_log_free(revs);
oidset_clear(&revs->missing_commits);
release_revisions_bloom_keyvecs(revs);
release_follow_pathspec_slab(revs);
}
static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)

View File

@@ -66,6 +66,7 @@ struct repository;
struct rev_info;
struct string_list;
struct saved_parents;
struct follow_pathspec_slab;
struct bloom_keyvec;
struct bloom_filter_settings;
struct option;
@@ -363,6 +364,9 @@ struct rev_info {
/* copies of the parent lists, for --full-diff display */
struct saved_parents *saved_parents_slab;
/* per-commit pathspec for --follow across merges */
struct follow_pathspec_slab *follow_pathspec_slab;
struct commit_list *previous_parents;
struct commit_list *ancestry_path_bottoms;
const char *break_bar;

View File

@@ -580,6 +580,7 @@ integration_tests = [
't4216-log-bloom.sh',
't4217-log-limit.sh',
't4218-log-graph-indentation.sh',
't4219-log-follow-merge.sh',
't4252-am-options.sh',
't4253-am-keep-cr-dos.sh',
't4254-am-corrupt.sh',

129
t/t4219-log-follow-merge.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/bin/sh
test_description='Test --follow follows renames across merges'
GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
. ./test-lib.sh
test_expect_success 'setup subtree-merged repository' '
git init inner &&
echo inner >inner/inner.txt &&
git -C inner add inner.txt &&
git -C inner commit -m "inner init" &&
git init outer &&
echo outer >outer/outer.txt &&
git -C outer add outer.txt &&
git -C outer commit -m "outer init" &&
git -C outer fetch ../inner master &&
git -C outer merge -s ours --no-commit --allow-unrelated-histories \
FETCH_HEAD &&
git -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&
git -C outer commit -m "Merge inner repo into inner/ subdirectory"
'
test_expect_success '--follow finds the pre-merge commit through a subtree merge' '
git -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&
echo "inner init" >expect &&
test_cmp expect actual
'
test_expect_success 'setup merge of two branches that both renamed a file to README' '
git init foo &&
mkdir foo/foo &&
echo "foo readme" >foo/foo/README &&
git -C foo add foo/README &&
git -C foo commit -m "add foo README" &&
git -C foo mv foo/README README &&
git -C foo commit -m "promote foo README to toplevel" &&
echo "foo c" >foo/foo.c &&
git -C foo add foo.c &&
git -C foo commit -m "add foo C impl" &&
git init bar &&
mkdir bar/bar &&
echo "bar readme" >bar/bar/README &&
git -C bar add bar/README &&
git -C bar commit -m "add bar README" &&
git -C bar mv bar/README README &&
git -C bar commit -m "promote bar README to toplevel" &&
echo "bar c" >bar/bar.c &&
git -C bar add bar.c &&
git -C bar commit -m "add bar C impl" &&
git -C foo fetch ../bar master &&
git -C foo merge -s ours --no-commit --allow-unrelated-histories \
FETCH_HEAD &&
git -C foo checkout FETCH_HEAD -- bar.c &&
git -C foo commit -m "merge bar into foo"
'
test_expect_success '--follow follows renames across both sides of a merge' '
git -C foo log --follow --pretty=tformat:%s README >actual &&
sort actual >actual.sorted &&
cat >expect <<-\EOF &&
add bar README
add foo README
promote bar README to toplevel
promote foo README to toplevel
EOF
test_cmp expect actual.sorted
'
test_expect_success 'setup diamond with renames on both sides of a fork' '
git init diamond &&
test_lines="line 1\nline 2\nline 3\nline 4\nline 5\n" &&
printf "$test_lines" >diamond/path0 &&
git -C diamond add path0 &&
git -C diamond commit -m "A: add path0" &&
git -C diamond checkout -b upper &&
printf "line 1\nline 2\nline 3 modified by B\nline 4\nline 5\n" \
>diamond/path0 &&
git -C diamond commit -am "B: modify path0 on upper" &&
git -C diamond mv path0 path1 &&
git -C diamond commit -m "X: rename path0 to path1" &&
git -C diamond checkout -b lower master &&
printf "line 1\nline 2\nline 3 modified by C\nline 4\nline 5\n" \
>diamond/path0 &&
git -C diamond commit -am "C: modify path0 on lower" &&
git -C diamond mv path0 path2 &&
git -C diamond commit -m "Y: rename path0 to path2" &&
git -C diamond checkout upper &&
git -C diamond merge -s ours --no-commit lower &&
git -C diamond rm path1 &&
printf "line 1\nline 2\nline 3 merged\nline 4\nline 5\n" \
>diamond/path &&
git -C diamond add path &&
git -C diamond commit -m "M: merge with rename to path" &&
printf "line 1\nline 2\nline 3 merged again\nline 4\nline 5\n" \
>diamond/path &&
git -C diamond commit -am "Z: modify path"
'
test_expect_success '--follow follows renames through a fork in a single history' '
git -C diamond log --follow --pretty=tformat:%s path >actual &&
sort actual >actual.sorted &&
cat >expect <<-\EOF &&
A: add path0
B: modify path0 on upper
C: modify path0 on lower
X: rename path0 to path1
Y: rename path0 to path2
Z: modify path
EOF
test_cmp expect actual.sorted
'
test_done