Skip to content

Commit

Permalink
cli: Add command jj annotate
Browse files Browse the repository at this point in the history
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
  • Loading branch information
allonsy committed Aug 13, 2024
1 parent ce29824 commit 176b802
Show file tree
Hide file tree
Showing 9 changed files with 660 additions and 0 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
This simplifies the use case of configuring code formatters for specific file
types. See `jj help fix` for details.

* Adds a new annotate command to annotate 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 diff --git` no longer shows the contents of binary files.
Expand Down
91 changes: 91 additions & 0 deletions cli/src/commands/annotate.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
3 changes: 3 additions & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

mod abandon;
mod annotate;
mod backout;
#[cfg(feature = "bench")]
mod bench;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions cli/src/config/templates.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
20 changes: 20 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. This command will fail if the file is in a conflicted state currently or in previous changes. 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
Expand Down
1 change: 1 addition & 0 deletions cli/tests/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions cli/tests/test_annotate_command.rs
Original file line number Diff line number Diff line change
@@ -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 [email protected] 2001-02-03 08:05:08 1: line1
kkmpptxz 41ae16e6 [email protected] 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 [email protected] 2001-02-03 08:05:08 1: line1
zsuskuln 712ba14a [email protected] 2001-02-03 08:05:11 2: new text from new commit 1
royxmykx b0571bd9 [email protected] 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 [email protected] 2001-02-03 08:05:08 1: line1
yostqsxw 7b90c9f6 [email protected] 2001-02-03 08:05:15 2: <<<<<<< Conflict 1 of 1
yostqsxw 7b90c9f6 [email protected] 2001-02-03 08:05:15 3: %%%%%%% Changes from base to side #1
yostqsxw 7b90c9f6 [email protected] 2001-02-03 08:05:15 4: +new text from new commit 1
yostqsxw 7b90c9f6 [email protected] 2001-02-03 08:05:15 5: +++++++ Contents of side #2
royxmykx b0571bd9 [email protected] 2001-02-03 08:05:13 6: new text from new commit 2
yostqsxw 7b90c9f6 [email protected] 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 [email protected] 2001-02-03 08:05:08 1: line1
zsuskuln 712ba14a [email protected] 2001-02-03 08:05:11 2: new text from new commit 1
"###);
}
Loading

0 comments on commit 176b802

Please sign in to comment.