Skip to content

Commit

Permalink
cli: Add command jj file 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 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
  • Loading branch information
allonsy committed Oct 15, 2024
1 parent f741102 commit 470275b
Show file tree
Hide file tree
Showing 9 changed files with 636 additions and 0 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* `jj git clone` now accepts a `--depth <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 <file_path>`.
The output can be customized via the `templates.annotate_commit_summary`
config variable.

### Fixed bugs

* Error on `trunk()` revset resolution is now handled gracefully.
Expand Down
94 changes: 94 additions & 0 deletions cli/src/commands/file/annotate.rs
Original file line number Diff line number Diff line change
@@ -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<RevisionArg>,
}

#[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<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/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand All @@ -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),
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, 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)),
Expand Down
20 changes: 20 additions & 0 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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] <PATH>`
###### **Arguments:**
* `<PATH>` — the file to annotate
###### **Options:**
* `-r`, `--revision <REVISION>` — an optional revision to start at
## `jj file chmod`
Sets or removes the executable bit for paths in the repo
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_bookmarks;
mod test_alias;
mod test_annotate_command;
mod test_backout_command;
mod test_bookmark_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, &["file", "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, &["file", "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, &["file", "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, &["file", "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 470275b

Please sign in to comment.