cli: Add command jj annotate

A new module is added to jj_lib which exposes a function
get_annotation_for_file. This annotates the given file line by line with
commit information according to the commit that made the most recent
change to the line.
Similarly, a new command is added to the CLI called `jj annotate` which
accepts a file path. It then prints out line by line the commit
information for the line and the line itself. Specific commit
information can be configured via the templates.annotate_commit_summary
config variable
This commit is contained in:
Alec Snyder 2024-07-22 22:16:10 +03:00
parent e803bed845
commit 93e1c2fcc5
9 changed files with 660 additions and 0 deletions

View File

@ -124,6 +124,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* `jj describe` can now update the description of multiple commits.
* New command `jj annotate` that annotates files line by line. This is similar
in functionality to git's blame. Invoke the command with `jj annotate <file_path>`.
The output can be customized via the `templates.annotate_commit_summary`
config variable.
### Fixed bugs
* `jj status` will show different messages in a conflicted tree, depending

View File

@ -0,0 +1,91 @@
// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use jj_lib::annotate::{get_annotation_for_file, AnnotateResults};
use jj_lib::backend::TreeValue;
use jj_lib::commit::Commit;
use jj_lib::repo::Repo;
use tracing::instrument;
use crate::cli_util::{CommandHelper, RevisionArg};
use crate::command_error::{user_error, CommandError, CommandErrorKind};
use crate::templater::TemplateRenderer;
use crate::ui::Ui;
/// Show the source change for each line of the target file.
///
/// Annotates a revision line by line. Each line includes the source change that
/// introduced the associated line. A path to the desired file must be provided.
/// The per-line prefix for each line can be customized via
/// template with the `templates.annotate_commit_summary` config variable.
#[derive(clap::Args, Clone, Debug)]
pub(crate) struct AnnotateArgs {
/// the file to annotate
#[arg(value_hint = clap::ValueHint::AnyPath)]
path: String,
/// an optional revision to start at
#[arg(long, short)]
revision: Option<RevisionArg>,
}
#[instrument(skip_all)]
pub(crate) fn cmd_annotate(
ui: &mut Ui,
command: &CommandHelper,
args: &AnnotateArgs,
) -> Result<(), CommandError> {
let workspace_command = command.workspace_helper(ui)?;
let repo = workspace_command.repo();
let starting_commit =
workspace_command.resolve_single_rev(args.revision.as_ref().unwrap_or(&RevisionArg::AT))?;
let file_path = workspace_command.parse_file_path(&args.path)?;
let file_value = starting_commit.tree()?.path_value(&file_path)?;
let ui_path = workspace_command.format_file_path(&file_path);
if file_value.is_absent() {
return Err(user_error(format!("No such path: {ui_path}")));
}
if let Some(TreeValue::Tree(_)) = file_value.as_normal() {
return Err(user_error(format!("file path is not a file: {ui_path}")));
}
let annotate_commit_summary_text = command
.settings()
.config()
.get_string("templates.annotate_commit_summary")?;
let template = workspace_command.parse_commit_template(&annotate_commit_summary_text)?;
let annotations = get_annotation_for_file(repo.as_ref(), &starting_commit, &file_path)
.map_err(|e| CommandError::new(CommandErrorKind::Internal, e))?;
render_annotations(repo.as_ref(), ui, template, &annotations)?;
Ok(())
}
fn render_annotations(
repo: &dyn Repo,
ui: &mut Ui,
template_render: TemplateRenderer<Commit>,
results: &AnnotateResults,
) -> Result<(), CommandError> {
ui.request_pager();
let mut formatter = ui.stdout_formatter();
for (line_no, (commit_id, line)) in results.file_annotations.iter().enumerate() {
let commit = repo.store().get_commit(commit_id)?;
template_render.format(&commit, formatter.as_mut())?;
write!(formatter, " {:>4}: ", line_no + 1)?;
formatter.write_all(line)?;
}
Ok(())
}

View File

@ -13,6 +13,7 @@
// limitations under the License.
mod abandon;
mod annotate;
mod backout;
#[cfg(feature = "bench")]
mod bench;
@ -69,6 +70,7 @@ use crate::ui::Ui;
#[derive(clap::Parser, Clone, Debug)]
enum Command {
Abandon(abandon::AbandonArgs),
Annotate(annotate::AnnotateArgs),
Backout(backout::BackoutArgs),
#[cfg(feature = "bench")]
#[command(subcommand)]
@ -170,6 +172,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co
let subcommand = Command::from_arg_matches(command_helper.matches()).unwrap();
match &subcommand {
Command::Abandon(args) => abandon::cmd_abandon(ui, command_helper, args),
Command::Annotate(args) => annotate::cmd_annotate(ui, command_helper, args),
Command::Backout(args) => backout::cmd_backout(ui, command_helper, args),
#[cfg(feature = "bench")]
Command::Bench(args) => bench::cmd_bench(ui, command_helper, args),

View File

@ -14,6 +14,15 @@ if(remote,
commit_summary = 'format_commit_summary_with_refs(self, branches)'
annotate_commit_summary = '''
separate(" ",
format_short_id(change_id),
format_short_id(commit_id),
format_short_signature(author),
format_timestamp(committer.timestamp()),
)
'''
config_list = '''
if(overridden,
label("overridden", indent("# ", name ++ " = " ++ value)),

View File

@ -12,6 +12,7 @@ This document contains the help content for the `jj` command-line program.
* [`jj`↴](#jj)
* [`jj abandon`↴](#jj-abandon)
* [`jj annotate`↴](#jj-annotate)
* [`jj backout`↴](#jj-backout)
* [`jj branch`↴](#jj-branch)
* [`jj branch create`↴](#jj-branch-create)
@ -111,6 +112,7 @@ To get started, see the tutorial at https://github.com/martinvonz/jj/blob/main/d
###### **Subcommands:**
* `abandon` — Abandon a revision
* `annotate` — Show the source change for each line of the target file
* `backout` — Apply the reverse of a revision on top of another revision
* `branch` — Manage branches
* `commit` — Update the description and create a new change on top
@ -209,6 +211,24 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T
## `jj annotate`
Show the source change for each line of the target file.
Annotates a revision line by line. Each line includes the source change that introduced the associated line. A path to the desired file must be provided. The per-line prefix for each line can be customized via template with the `templates.annotate_commit_summary` config variable.
**Usage:** `jj annotate [OPTIONS] <PATH>`
###### **Arguments:**
* `<PATH>` — the file to annotate
###### **Options:**
* `-r`, `--revision <REVISION>` — an optional revision to start at
## `jj backout`
Apply the reverse of a revision on top of another revision

View File

@ -12,6 +12,7 @@ mod test_abandon_command;
mod test_acls;
mod test_advance_branches;
mod test_alias;
mod test_annotate_command;
mod test_backout_command;
mod test_branch_command;
mod test_builtin_aliases;

View File

@ -0,0 +1,148 @@
// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use crate::common::TestEnvironment;
fn append_to_file(file_path: &Path, contents: &str) {
let mut options = OpenOptions::new();
options.append(true);
let mut file = options.open(file_path).unwrap();
writeln!(file, "{}", contents).unwrap();
}
#[test]
fn test_annotate_linear() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");
std::fs::write(repo_path.join("file.txt"), "line1\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["describe", "-m=initial"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=next"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit");
let stdout = test_env.jj_cmd_success(&repo_path, &["annotate", "file.txt"]);
insta::assert_snapshot!(stdout, @r###"
qpvuntsm 8934c772 test.user@example.com 2001-02-03 08:05:08 1: line1
kkmpptxz 41ae16e6 test.user@example.com 2001-02-03 08:05:10 2: new text from new commit
"###);
}
#[test]
fn test_annotate_merge() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");
std::fs::write(repo_path.join("file.txt"), "line1\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["describe", "-m=initial"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "initial"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit1"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 1");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit1"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit2", "initial"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 2");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit2"]);
// create a (conflicted) merge
test_env.jj_cmd_ok(&repo_path, &["new", "-m=merged", "commit1", "commit2"]);
// resolve conflicts
std::fs::write(
repo_path.join("file.txt"),
"line1\nnew text from new commit 1\nnew text from new commit 2\n",
)
.unwrap();
let stdout = test_env.jj_cmd_success(&repo_path, &["annotate", "file.txt"]);
insta::assert_snapshot!(stdout, @r###"
qpvuntsm 8934c772 test.user@example.com 2001-02-03 08:05:08 1: line1
zsuskuln 712ba14a test.user@example.com 2001-02-03 08:05:11 2: new text from new commit 1
royxmykx b0571bd9 test.user@example.com 2001-02-03 08:05:13 3: new text from new commit 2
"###);
}
#[test]
fn test_annotate_conflicted() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");
std::fs::write(repo_path.join("file.txt"), "line1\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["describe", "-m=initial"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "initial"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit1"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 1");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit1"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit2", "initial"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 2");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit2"]);
// create a (conflicted) merge
test_env.jj_cmd_ok(&repo_path, &["new", "-m=merged", "commit1", "commit2"]);
test_env.jj_cmd_ok(&repo_path, &["new"]);
let stdout = test_env.jj_cmd_success(&repo_path, &["annotate", "file.txt"]);
insta::assert_snapshot!(stdout, @r###"
qpvuntsm 8934c772 test.user@example.com 2001-02-03 08:05:08 1: line1
yostqsxw 7b90c9f6 test.user@example.com 2001-02-03 08:05:15 2: <<<<<<< Conflict 1 of 1
yostqsxw 7b90c9f6 test.user@example.com 2001-02-03 08:05:15 3: %%%%%%% Changes from base to side #1
yostqsxw 7b90c9f6 test.user@example.com 2001-02-03 08:05:15 4: +new text from new commit 1
yostqsxw 7b90c9f6 test.user@example.com 2001-02-03 08:05:15 5: +++++++ Contents of side #2
royxmykx b0571bd9 test.user@example.com 2001-02-03 08:05:13 6: new text from new commit 2
yostqsxw 7b90c9f6 test.user@example.com 2001-02-03 08:05:15 7: >>>>>>> Conflict 1 of 1 ends
"###);
}
#[test]
fn test_annotate_merge_one_sided_conflict_resolution() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");
std::fs::write(repo_path.join("file.txt"), "line1\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["describe", "-m=initial"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "initial"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit1"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 1");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit1"]);
test_env.jj_cmd_ok(&repo_path, &["new", "-m=commit2", "initial"]);
append_to_file(&repo_path.join("file.txt"), "new text from new commit 2");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "commit2"]);
// create a (conflicted) merge
test_env.jj_cmd_ok(&repo_path, &["new", "-m=merged", "commit1", "commit2"]);
// resolve conflicts
std::fs::write(
repo_path.join("file.txt"),
"line1\nnew text from new commit 1\n",
)
.unwrap();
let stdout = test_env.jj_cmd_success(&repo_path, &["annotate", "file.txt"]);
insta::assert_snapshot!(stdout, @r###"
qpvuntsm 8934c772 test.user@example.com 2001-02-03 08:05:08 1: line1
zsuskuln 712ba14a test.user@example.com 2001-02-03 08:05:11 2: new text from new commit 1
"###);
}

382
lib/src/annotate.rs Normal file
View File

@ -0,0 +1,382 @@
// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Methods that allow annotation (attribution and blame) for a file in a
//! repository.
//!
//! TODO: Add support for different blame layers with a trait in the future.
//! Like commit metadata and more.
use std::collections::HashMap;
use pollster::FutureExt;
use thiserror::Error;
use crate::backend::{BackendError, CommitId};
use crate::commit::Commit;
use crate::conflicts::{materialize_tree_value, MaterializedTreeValue};
use crate::diff::{Diff, DiffHunk};
use crate::fileset::FilesetExpression;
use crate::graph::{GraphEdge, GraphEdgeType};
use crate::merged_tree::MergedTree;
use crate::repo::Repo;
use crate::repo_path::RepoPath;
use crate::revset::{RevsetEvaluationError, RevsetExpression, RevsetFilterPredicate};
use crate::store::Store;
/// Various errors that can arise from annotation
#[derive(Debug, Error)]
pub enum AnnotateError {
/// the requested file path was not found
#[error("Unable to locate file: {0}")]
FileNotFound(String),
/// the file type is incorrect. Usually a directory was given but a regular
/// file is required
#[error("File {0} must be a regular file, not a directory")]
UnsupportedFileType(String),
/// the file is in a conflicted state and can therefore not be annotated
/// properly
#[error("File {0} is conflicted at commit: {1}")]
Conflicted(String, String),
/// pass-through of uncaught backend errors
#[error(transparent)]
BackendError(#[from] BackendError),
}
/// Annotation results for a specific file
pub struct AnnotateResults {
/// An array of annotation results ordered by line.
/// For each value in the array, the commit_id is the commit id of the
/// originator of the line and the string is the actual line itself (without
/// newline terminators). The vector is ordered by appearance in the
/// file
pub file_annotations: Vec<(CommitId, Vec<u8>)>,
}
/// A note on implementation:
/// This structure represents the results along the way.
/// We first start at the original commit, for each commit, we compare the file
/// to the version in each parent. We only look at lines in common. For each
/// line in common, we add it to local_line_map according to how the lines match
/// up. If, we discover a line that is not in common with any parent commit, we
/// know that the current commit originated that line and we add it to
/// original_line_map.
/// We then proceed to walk through the graph, until we've found commits for
/// each line (local_line_map is empty when this happens)
struct PartialResults {
/// A mapping from line_number in the original file to most recent commit
/// that changed it.
original_line_map: HashMap<usize, CommitId>,
/// CommitId -> (line_number in CommitId -> line_number in the original).
/// This is a map for a given commit_id, returns a mapping of line numbers
/// in the file version at commit_id to the original version.
/// For example, Commit 123 contains a map {(1, 1), (2, 3)} which means line
/// 1 at 123 goes to the original line 1 and line 2 at 123 goes to line 3 at
/// the original
local_line_map: HashMap<CommitId, HashMap<usize, usize>>,
/// A store of previously seen files
file_cache: HashMap<CommitId, Vec<u8>>,
}
impl PartialResults {
fn new(starting_commit_id: &CommitId, num_lines: usize) -> Self {
let mut starting_map = HashMap::new();
for i in 0..num_lines {
starting_map.insert(i, i);
}
let mut results = PartialResults {
original_line_map: HashMap::new(),
local_line_map: HashMap::new(),
file_cache: HashMap::new(),
};
results
.local_line_map
.insert(starting_commit_id.clone(), starting_map);
results
}
/// Take a line mapping from an old commit and move it to a new commit.
/// For example, if we figure out that line 2 in commit A maps to line 7 in
/// the original, and line 3 in commit B maps to line 2 in commit A, we
/// update the mapping so line 3 maps to line 7 in the original.
fn forward_to_new_commit(
&mut self,
old_commit_id: &CommitId,
old_local_line_number: usize,
new_commit_id: &CommitId,
new_local_line_number: usize,
) {
if let Some(old_map) = self.local_line_map.get_mut(old_commit_id) {
if let Some(removed_original_line_number) = old_map.remove(&old_local_line_number) {
if self.local_line_map.contains_key(new_commit_id) {
self.local_line_map
.get_mut(new_commit_id)
.unwrap()
.insert(new_local_line_number, removed_original_line_number);
} else {
let mut new_map = HashMap::new();
new_map.insert(new_local_line_number, removed_original_line_number);
self.local_line_map.insert(new_commit_id.clone(), new_map);
}
}
}
}
/// Once we've looked at all parents of a commit, any leftover lines must be
/// original to the current commit, so we save this information in
/// original_line_map.
fn drain_remaining_for_commit_id(&mut self, commit_id: &CommitId) {
self.file_cache.remove(commit_id);
if let Some(remaining_lines) = self.local_line_map.remove(commit_id) {
for (_, original_line_number) in remaining_lines {
self.original_line_map
.insert(original_line_number, commit_id.clone());
}
}
}
fn convert_to_results(self, original_contents: &[u8]) -> AnnotateResults {
let mut result_lines = Vec::new();
original_contents
.split_inclusive(|b| *b == b'\n')
.enumerate()
.for_each(|(idx, line)| {
result_lines.push((
self.original_line_map.get(&idx).unwrap().clone(),
line.to_owned(),
));
});
AnnotateResults {
file_annotations: result_lines,
}
}
/// loads a given file into the cache under a specific commit id.
/// If there is already a file for a given commit, it is a no-op.
fn load_file_into_cache(
&mut self,
store: &Store,
commit_id: &CommitId,
file_path: &RepoPath,
tree: &MergedTree,
) -> Result<(), AnnotateError> {
if self.file_cache.contains_key(commit_id) {
return Ok(());
}
if let Some(file_contents) = get_file_contents(store, file_path, tree)? {
self.file_cache.insert(commit_id.clone(), file_contents);
}
Ok(())
}
}
/// Get line by line annotations for a specific file path in the repo.
pub fn get_annotation_for_file(
repo: &dyn Repo,
starting_commit: &Commit,
file_path: &RepoPath,
) -> Result<AnnotateResults, AnnotateError> {
if let Some(original_contents) =
get_file_contents(starting_commit.store(), file_path, &starting_commit.tree()?)?
{
let num_lines = original_contents.split_inclusive(|b| *b == b'\n').count();
let mut partial_results = PartialResults::new(starting_commit.id(), num_lines);
process_commits(
repo,
starting_commit.id(),
&mut partial_results,
file_path,
num_lines,
)?;
Ok(partial_results.convert_to_results(&original_contents))
} else {
Err(AnnotateError::FileNotFound(
file_path.as_internal_file_string().to_string(),
))
}
}
/// Starting at the starting commit, compute changes at that commit, updating
/// the mappings. So long as there are mappings left in local_line_map, we
/// continue. Once local_line_map is empty, we've found sources for each line
/// and exit.
fn process_commits(
repo: &dyn Repo,
starting_commit_id: &CommitId,
results: &mut PartialResults,
file_name: &RepoPath,
num_lines: usize,
) -> Result<(), AnnotateError> {
let predicate = RevsetFilterPredicate::File(FilesetExpression::file_path(file_name.to_owned()));
let revset = RevsetExpression::commit(starting_commit_id.clone())
.union(
&RevsetExpression::commit(starting_commit_id.clone())
.ancestors()
.filtered(predicate),
)
.evaluate_programmatic(repo)
.map_err(|e| match e {
RevsetEvaluationError::StoreError(backend_error) => AnnotateError::from(backend_error),
RevsetEvaluationError::Other(_) => {
panic!("Unable to evaluate internal revset")
}
})?;
for (cid, edge_list) in revset.iter_graph() {
let current_commit = repo.store().get_commit(&cid)?;
process_commit(results, repo, file_name, &current_commit, &edge_list)?;
if results.original_line_map.len() >= num_lines {
break;
}
}
Ok(())
}
/// For a given commit, for each parent, we compare the version in the parent
/// tree with the current version, updating the mappings for any lines in
/// common. If the parent doesn't have the file, we skip it.
/// After iterating through all the parents, any leftover lines unmapped means
/// that those lines are original in the current commit. In that case,
/// original_line_map is updated for the leftover lines.
fn process_commit(
results: &mut PartialResults,
repo: &dyn Repo,
file_name: &RepoPath,
current_commit: &Commit,
edges: &Vec<GraphEdge<CommitId>>,
) -> Result<(), AnnotateError> {
for parent_edge in edges {
if parent_edge.edge_type != GraphEdgeType::Missing {
let parent_commit = repo.store().get_commit(&parent_edge.target)?;
process_files_in_commits(
results,
repo.store(),
file_name,
current_commit,
&parent_commit,
)?;
}
}
results.drain_remaining_for_commit_id(current_commit.id());
Ok(())
}
/// For two versions of the same file, for all the lines in common, overwrite
/// the new mapping in the results for the new commit. Let's say I have
/// a file in commit A and commit B. We know that according to local_line_map,
/// in commit A, line 3 corresponds to line 7 of the original file. Now, line 3
/// in Commit A corresponds to line 6 in commit B. Then, we update
/// local_line_map to say that "Commit B line 6 goes to line 7 of the original
/// file". We repeat this for all lines in common in the two commits. For 2
/// identical files, we bulk replace all mappings from commit A to commit B in
/// local_line_map
fn process_files_in_commits(
results: &mut PartialResults,
store: &Store,
file_name: &RepoPath,
current_commit: &Commit,
parent_commit: &Commit,
) -> Result<(), AnnotateError> {
results.load_file_into_cache(
store,
current_commit.id(),
file_name,
&current_commit.tree()?,
)?;
results.load_file_into_cache(store, parent_commit.id(), file_name, &parent_commit.tree()?)?;
let current_contents = results.file_cache.get(current_commit.id()).unwrap();
let parent_contents = results.file_cache.get(parent_commit.id()).unwrap();
let same_lines = get_same_line_map(current_contents, parent_contents);
for (current_line_no, parent_line_no) in same_lines {
results.forward_to_new_commit(
current_commit.id(),
current_line_no,
parent_commit.id(),
parent_line_no,
);
}
Ok(())
}
/// For two files, get a map of all lines in common (e.g. line 8 maps to line 9)
fn get_same_line_map(current_contents: &[u8], parent_contents: &[u8]) -> HashMap<usize, usize> {
let mut result_map = HashMap::new();
let inputs = vec![current_contents, parent_contents];
let diff = Diff::by_line(&inputs);
let mut current_line_counter: usize = 0;
let mut parent_line_counter: usize = 0;
for hunk in diff.hunks() {
match hunk {
DiffHunk::Matching(common_string) => {
for _ in common_string.split_inclusive(|b| *b == b'\n') {
result_map.insert(current_line_counter, parent_line_counter);
current_line_counter += 1;
parent_line_counter += 1;
}
}
DiffHunk::Different(outputs) => {
let current_output = outputs[0];
let parent_output = outputs[1];
if !current_output.is_empty() {
for _ in current_output.split_inclusive(|b| *b == b'\n') {
current_line_counter += 1;
}
}
if !parent_output.is_empty() {
for _ in parent_output.split_inclusive(|b| *b == b'\n') {
parent_line_counter += 1;
}
}
}
}
}
result_map
}
fn get_file_contents(
store: &Store,
path: &RepoPath,
tree: &MergedTree,
) -> Result<Option<Vec<u8>>, AnnotateError> {
let file_value = tree.path_value(path)?;
if file_value.is_absent() {
Ok(None)
} else {
let effective_file_value = materialize_tree_value(store, path, file_value).block_on()?;
match effective_file_value {
MaterializedTreeValue::File { mut reader, id, .. } => {
let mut file_contents = Vec::new();
let _ =
reader
.read_to_end(&mut file_contents)
.map_err(|e| BackendError::ReadFile {
path: path.to_owned(),
id,
source: Box::new(e),
});
Ok(Some(file_contents))
}
MaterializedTreeValue::Conflict { contents, .. } => Ok(Some(contents)),
_ => Ok(None),
}
}
}

View File

@ -28,6 +28,7 @@ extern crate self as jj_lib;
#[macro_use]
pub mod content_hash;
pub mod annotate;
pub mod backend;
pub mod commit;
pub mod commit_builder;