mirror of
https://github.com/martinvonz/jj.git
synced 2024-09-11 13:56:29 +03:00
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:
parent
e803bed845
commit
93e1c2fcc5
@ -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
|
||||
|
91
cli/src/commands/annotate.rs
Normal file
91
cli/src/commands/annotate.rs
Normal 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(())
|
||||
}
|
@ -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),
|
||||
|
@ -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)),
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
148
cli/tests/test_annotate_command.rs
Normal file
148
cli/tests/test_annotate_command.rs
Normal 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
382
lib/src/annotate.rs
Normal 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, ¤t_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,
|
||||
¤t_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),
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user