From 470275b0d0f258c11d2393e8a55d92f604237078 Mon Sep 17 00:00:00 2001 From: Alec Snyder Date: Mon, 22 Jul 2024 22:16:10 +0300 Subject: [PATCH] cli: Add command `jj file 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 file 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 --- CHANGELOG.md | 5 + cli/src/commands/file/annotate.rs | 94 ++++++++ cli/src/commands/file/mod.rs | 3 + cli/src/config/templates.toml | 9 + cli/tests/cli-reference@.md.snap | 20 ++ cli/tests/runner.rs | 1 + cli/tests/test_annotate_command.rs | 148 ++++++++++++ lib/src/annotate.rs | 355 +++++++++++++++++++++++++++++ lib/src/lib.rs | 1 + 9 files changed, 636 insertions(+) create mode 100644 cli/src/commands/file/annotate.rs create mode 100644 cli/tests/test_annotate_command.rs create mode 100644 lib/src/annotate.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c157282605..15ff115076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj git clone` now accepts a `--depth ` option, which allows to clone the repository with a given depth. +* New command `jj file annotate` that annotates files line by line. This is similar + in functionality to git's blame. Invoke the command with `jj file annotate `. + The output can be customized via the `templates.annotate_commit_summary` + config variable. + ### Fixed bugs * Error on `trunk()` revset resolution is now handled gracefully. diff --git a/cli/src/commands/file/annotate.rs b/cli/src/commands/file/annotate.rs new file mode 100644 index 0000000000..4161075d65 --- /dev/null +++ b/cli/src/commands/file/annotate.rs @@ -0,0 +1,94 @@ +// 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; +use jj_lib::annotate::AnnotateResults; +use jj_lib::commit::Commit; +use jj_lib::repo::Repo; +use tracing::instrument; + +use crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::command_error::user_error; +use crate::command_error::CommandError; +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 FileAnnotateArgs { + /// the file to annotate + #[arg(value_hint = clap::ValueHint::AnyPath)] + path: String, + /// an optional revision to start at + #[arg(long, short)] + revision: Option, +} + +#[instrument(skip_all)] +pub(crate) fn cmd_file_annotate( + ui: &mut Ui, + command: &CommandHelper, + args: &FileAnnotateArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let starting_commit = workspace_command + .resolve_single_rev(ui, 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 file_value.is_tree() { + return Err(user_error(format!( + "Path exists but is not a regular file: {ui_path}" + ))); + } + + let annotate_commit_summary_text = command + .settings() + .config() + .get_string("templates.annotate_commit_summary")?; + let template = workspace_command.parse_commit_template(ui, &annotate_commit_summary_text)?; + + let annotations = get_annotation_for_file(repo.as_ref(), &starting_commit, &file_path)?; + + render_annotations(repo.as_ref(), ui, &template, &annotations)?; + Ok(()) +} + +fn render_annotations( + repo: &dyn Repo, + ui: &mut Ui, + template_render: &TemplateRenderer, + 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(()) +} diff --git a/cli/src/commands/file/mod.rs b/cli/src/commands/file/mod.rs index 5bb528cb25..81d659b39d 100644 --- a/cli/src/commands/file/mod.rs +++ b/cli/src/commands/file/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod annotate; pub mod chmod; pub mod list; pub mod show; @@ -25,6 +26,7 @@ use crate::ui::Ui; /// File operations. #[derive(clap::Subcommand, Clone, Debug)] pub enum FileCommand { + Annotate(annotate::FileAnnotateArgs), Chmod(chmod::FileChmodArgs), List(list::FileListArgs), Show(show::FileShowArgs), @@ -38,6 +40,7 @@ pub fn cmd_file( subcommand: &FileCommand, ) -> Result<(), CommandError> { match subcommand { + FileCommand::Annotate(args) => annotate::cmd_file_annotate(ui, command, args), FileCommand::Chmod(args) => chmod::cmd_file_chmod(ui, command, args), FileCommand::List(args) => list::cmd_file_list(ui, command, args), FileCommand::Show(args) => show::cmd_file_show(ui, command, args), diff --git a/cli/src/config/templates.toml b/cli/src/config/templates.toml index 80f79f24dc..26bc0d793a 100644 --- a/cli/src/config/templates.toml +++ b/cli/src/config/templates.toml @@ -14,6 +14,15 @@ if(remote, commit_summary = 'format_commit_summary_with_refs(self, bookmarks)' +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)), diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index a3c6526ffa..7c569c9e0c 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -37,6 +37,7 @@ This document contains the help content for the `jj` command-line program. * [`jj edit`↴](#jj-edit) * [`jj evolog`↴](#jj-evolog) * [`jj file`↴](#jj-file) +* [`jj file annotate`↴](#jj-file-annotate) * [`jj file chmod`↴](#jj-file-chmod) * [`jj file list`↴](#jj-file-list) * [`jj file show`↴](#jj-file-show) @@ -760,6 +761,7 @@ File operations ###### **Subcommands:** +* `annotate` — Show the source change for each line of the target file * `chmod` — Sets or removes the executable bit for paths in the repo * `list` — List files in a revision * `show` — Print contents of files in a revision @@ -768,6 +770,24 @@ File operations +## `jj file 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 file annotate [OPTIONS] ` + +###### **Arguments:** + +* `` — the file to annotate + +###### **Options:** + +* `-r`, `--revision ` — an optional revision to start at + + + ## `jj file chmod` Sets or removes the executable bit for paths in the repo diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 7ecc1d7b98..f63f126599 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -12,6 +12,7 @@ mod test_abandon_command; mod test_acls; mod test_advance_bookmarks; mod test_alias; +mod test_annotate_command; mod test_backout_command; mod test_bookmark_command; mod test_builtin_aliases; diff --git a/cli/tests/test_annotate_command.rs b/cli/tests/test_annotate_command.rs new file mode 100644 index 0000000000..32f941c73a --- /dev/null +++ b/cli/tests/test_annotate_command.rs @@ -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, &["file", "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, &["file", "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, &["file", "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, &["file", "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 + "###); +} diff --git a/lib/src/annotate.rs b/lib/src/annotate.rs new file mode 100644 index 0000000000..6048ece912 --- /dev/null +++ b/lib/src/annotate.rs @@ -0,0 +1,355 @@ +// 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 crate::backend::BackendError; +use crate::backend::CommitId; +use crate::commit::Commit; +use crate::conflicts::materialize_merge_result; +use crate::conflicts::materialize_tree_value; +use crate::conflicts::MaterializedTreeValue; +use crate::diff::Diff; +use crate::diff::DiffHunkKind; +use crate::fileset::FilesetExpression; +use crate::graph::GraphEdge; +use crate::graph::GraphEdgeType; +use crate::merged_tree::MergedTree; +use crate::repo::Repo; +use crate::repo_path::RepoPath; +use crate::revset::RevsetEvaluationError; +use crate::revset::RevsetExpression; +use crate::revset::RevsetFilterPredicate; +use crate::store::Store; + +/// 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)>, +} + +/// A map from commits to line mappings. +/// Namely, for a given commit A, the value is the mapping of lines in the file +/// at commit A to line numbers in the original file +type CommitLineMap = HashMap>; + +/// Memoizes the file contents for a given version to save time +type FileCache = HashMap>; + +/// A map from line numbers in the original file to the commit that originated +/// that line +type OriginalLineMap = HashMap; + +fn get_initial_commit_line_map(commit_id: &CommitId, num_lines: usize) -> CommitLineMap { + let mut starting_commit_map = HashMap::new(); + for i in 0..num_lines { + starting_commit_map.insert(i, i); + } + + let mut starting_line_map = HashMap::new(); + starting_line_map.insert(commit_id.clone(), starting_commit_map); + starting_line_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 mark_lines_from_original( + original_line_map: &mut OriginalLineMap, + commit_id: &CommitId, + commit_lines: HashMap, +) { + for (_, original_line_number) in commit_lines { + original_line_map.insert(original_line_number, commit_id.clone()); + } +} + +/// Takes in an original line map and the original contents and annotates each +/// line according to the contents of the provided OriginalLineMap +fn convert_to_results( + original_line_map: OriginalLineMap, + original_contents: &[u8], +) -> AnnotateResults { + let result_lines = original_contents + .split_inclusive(|b| *b == b'\n') + .enumerate() + .map(|(idx, line)| { + ( + original_line_map.get(&idx).unwrap().clone(), + line.to_owned(), + ) + }) + .collect(); + 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( + file_cache: &mut FileCache, + store: &Store, + commit_id: &CommitId, + file_path: &RepoPath, + tree: &MergedTree, +) -> Result<(), BackendError> { + if file_cache.contains_key(commit_id) { + return Ok(()); + } + + let file_contents = get_file_contents(store, file_path, tree)?; + file_cache.insert(commit_id.clone(), file_contents); + + Ok(()) +} + +/// Get line by line annotations for a specific file path in the repo. +/// If the file is not found, returns empty results. +pub fn get_annotation_for_file( + repo: &dyn Repo, + starting_commit: &Commit, + file_path: &RepoPath, +) -> Result { + let 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 file_cache = HashMap::new(); + file_cache.insert(starting_commit.id().clone(), original_contents.clone()); + + let original_line_map = + process_commits(repo, file_cache, starting_commit.id(), file_path, num_lines)?; + + Ok(convert_to_results(original_line_map, &original_contents)) +} + +/// Starting at the starting commit, compute changes at that commit relative to +/// it's direct parents, updating the mappings as we go. We return the final +/// original line map that represents where each line of the original came from. +fn process_commits( + repo: &dyn Repo, + mut file_cache: FileCache, + starting_commit_id: &CommitId, + file_name: &RepoPath, + num_lines: usize, +) -> Result { + 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) => backend_error, + RevsetEvaluationError::Other(_) => { + panic!("Unable to evaluate internal revset") + } + })?; + let mut commit_line_map = get_initial_commit_line_map(starting_commit_id, num_lines); + let mut original_line_map = HashMap::new(); + + for (cid, edge_list) in revset.iter_graph() { + let current_commit = repo.store().get_commit(&cid)?; + process_commit( + repo, + file_name, + &mut original_line_map, + &mut commit_line_map, + ¤t_commit, + &mut file_cache, + &edge_list, + )?; + if original_line_map.len() >= num_lines { + break; + } + } + Ok(original_line_map) +} + +/// 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. +/// We return the lines that are the same in the child commit and +/// any parent. Namely, if line x is found in parent Y, we record the mapping +/// that parent Y has line x. The line mappings for all parents are returned +/// along with any lines originated in the current commit +fn process_commit( + repo: &dyn Repo, + file_name: &RepoPath, + original_line_map: &mut HashMap, + commit_line_map: &mut CommitLineMap, + current_commit: &Commit, + file_cache: &mut FileCache, + edges: &[GraphEdge], +) -> Result<(), BackendError> { + if let Some(mut current_commit_line_map) = commit_line_map.remove(current_commit.id()) { + for parent_edge in edges { + if parent_edge.edge_type != GraphEdgeType::Missing { + let parent_commit = repo.store().get_commit(&parent_edge.target)?; + let same_line_map = process_files_in_commits( + repo.store(), + file_name, + file_cache, + current_commit, + &parent_commit, + )?; + + let parent_commit_line_map = commit_line_map + .entry(parent_commit.id().clone()) + .or_default(); + + for (current_line_number, parent_line_number) in same_line_map { + if let Some(original_line_number) = + current_commit_line_map.remove(¤t_line_number) + { + // forward current line to parent commit since it is in common + parent_commit_line_map.insert(parent_line_number, original_line_number); + } + } + if parent_commit_line_map.is_empty() { + commit_line_map.remove(parent_commit.id()); + } + } + } + if !current_commit_line_map.is_empty() { + mark_lines_from_original( + original_line_map, + current_commit.id(), + current_commit_line_map, + ); + } + let _ = file_cache.remove(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( + store: &Store, + file_name: &RepoPath, + file_cache: &mut FileCache, + current_commit: &Commit, + parent_commit: &Commit, +) -> Result, BackendError> { + load_file_into_cache( + file_cache, + store, + current_commit.id(), + file_name, + ¤t_commit.tree()?, + )?; + load_file_into_cache( + file_cache, + store, + parent_commit.id(), + file_name, + &parent_commit.tree()?, + )?; + + let current_contents = file_cache.get(current_commit.id()).unwrap(); + let parent_contents = file_cache.get(parent_commit.id()).unwrap(); + + Ok(get_same_line_map(current_contents, parent_contents)) +} + +/// 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 { + let mut result_map = HashMap::new(); + let diff = Diff::by_line([current_contents, parent_contents]); + let mut current_line_counter: usize = 0; + let mut parent_line_counter: usize = 0; + for hunk in diff.hunks() { + match hunk.kind { + DiffHunkKind::Matching => { + for _ in hunk.contents[0].split_inclusive(|b| *b == b'\n') { + result_map.insert(current_line_counter, parent_line_counter); + current_line_counter += 1; + parent_line_counter += 1; + } + } + DiffHunkKind::Different => { + let current_output = hunk.contents[0]; + let parent_output = hunk.contents[1]; + current_line_counter += current_output.split_inclusive(|b| *b == b'\n').count(); + parent_line_counter += parent_output.split_inclusive(|b| *b == b'\n').count(); + } + } + } + + result_map +} + +fn get_file_contents( + store: &Store, + path: &RepoPath, + tree: &MergedTree, +) -> Result, BackendError> { + let file_value = tree.path_value(path)?; + if file_value.is_absent() { + Ok(Vec::new()) + } 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(); + reader + .read_to_end(&mut file_contents) + .map_err(|e| BackendError::ReadFile { + path: path.to_owned(), + id, + source: Box::new(e), + })?; + Ok(file_contents) + } + MaterializedTreeValue::FileConflict { id, contents, .. } => { + let mut materialized_conflict_buffer = Vec::new(); + materialize_merge_result(&contents, &mut materialized_conflict_buffer).map_err( + |io_err| BackendError::ReadFile { + path: path.to_owned(), + source: Box::new(io_err), + id: id.first().clone().unwrap(), + }, + )?; + Ok(materialized_conflict_buffer) + } + _ => Ok(Vec::new()), + } + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 60172d7622..d629ba88c1 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -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;