Repository: jj-vcs/jj (Rust)

Issue:
"exact" modifier does not seem to work (at least when used in a revset within a describe() expression)
## Description

<!-- Thanks for your report! Please describe your problem or request here.
For questions, use https://github.com/jj-vcs/jj/discussions/new instead.
Feel free to remove any of the sections below if they don't seem useful. -->


## Steps to Reproduce the Problem

1. jj new -m "my_unique_test_description"
1. jj log -r "describe(exact:"my_unique_test_description")
1. Returns no revisions

## Expected Behavior

It should return the revisions exactly matching "my_unique_test_description" (normally it should just be one)

## Actual Behavior
No revisions are matched

## Specifications

- Platform: Windows
- Version: 0.24



Discussion:
Does `exact` mean that the entire content matches exactly or that an exact match is contained in the content?
I've run into this before. I think it's because technically, the description ends with a newline if it's non-empty. So `description(exact:"")` matches empty description, and `description(exact:"my_unique_test_description\n")` matches "my_unique_test_description".
> I've run into this before. I think it's because technically, the description ends with a newline if it's non-empty. So `description(exact:"")` matches empty description, and `description(exact:"my_unique_test_description\n")` matches "my_unique_test_description".

That's probably it. I think the revset doc can be updated.
Another confusing thing is that `description(exact:'my_unique_test_description\n')` doesn't work. A literal newline like

```shell
jj log -r 'description(exact:\'test
\')'
```

or

```shell
jj log -r "description(exact:'test
')"
```

does work (in `fish` at least).

Perhaps `description` should match both with and without a trailing `\n`, since `jj` already adds the `\n` automatically? I suppose we could have `strict_description` that acts differently. This isn't perfect, but perhaps it's better than the status quo. Or can we think of something even better?

I suppose there could be a version of `exact` that trims whitespace.
Well, `''` means raw string (i.e disable escapes).

I don't think exact match on description is useful (except for `exact:""`.) It's probably better to suggest regex for line-based matching than adding special case for `description()`.
What may be useful is having a query (and template?) for only the subject line of a description, which can be fairly useful to search exactly (e.g. commit subjects mentioned in emails), and could naturally strip the trailing newline.
> I don't think exact match on description is useful (except for exact:"".) It's probably better to suggest regex for line-based matching than adding special case for description()

I think it could be quite useful if you use certain specific descriptions for certain revisions (e.g. CI/CD, etc).
In any case, the current behavior is _very_ surprising. If you set the description to "test" by using `jj describe -m "test"` one would expect to find that specific revision by doing `jj log -r "description(exact:'test')", wouldn't it? The fact that jj adds a trailing '\n' should not leak to the UX, IMHO.

> What may be useful is having a query (and template?) for only the subject line of a description, which can be fairly useful to search exactly (e.g. commit subjects mentioned in emails), and could naturally strip the trailing newline.

FWIW mercurial does have a "matching" function that can take "summary" as its "field" argument, which looks for the first line of each commit message.
FWIW, `jj describe -m` has added since PR #936. `git commit -m` seems to do the same thing but `hg commit -m` does not.
Since due to PR #936 it is _not_ possible to have a description without a final new line, it seems to me that it would make sense for `exact` to automatically add a new line to the query string (if it is not already there). Otherwise it will _never_ return any result, which is very surprising given the example I provided above (i.e. doing `jj describe -m "test"` and then `jj log -r "description(exact:'test')"` and not getting any result).

I'm willing to try to create a PR for this if you think this would be a good solution.
> it seems to me that it would make sense for exact to automatically add a new line to the query string (if it is not already there)

I don't think this kind of ad-hoc change is good idea. I'd rather add `description_first_line()` (or maybe `subject()`?) instead.
I like the idea of adding a `subject()"` function. However, I still believe that setting the description to `X` and not being able to find it by searching for `exact:X` is _very_ surprising. The fact that jj adds a `\n` under the hood should not leak into the user interface.

Also, what's the point of an `exact` modifier that will _never_ work? AFACT currently exact never returns any revisions (except if you search for an empty string)! IMHO that means that `exact` is broken when used in combination with `describe()`.
> I still believe that setting the description to `X` and not being able to find it by searching for `exact:X` is _very_ surprising. The fact that jj adds a `\n` under the hood should not leak into the user interface.

I won't disagree that it's surprising, but the normalization rule isn't an implementation detail. It's something that an editor appends newline on save.

> Also, what's the point of an `exact` modifier that will _never_ work?

It works if you add `"\n"` (or the underlying commit description lacks `"\n"`.) It's weird if both `exact:"foo"` and `exact:"foo\n"` exactly matches the same data.

FWIW, `git`/`jj` CLI usually appends `"\n"`, but that's not guaranteed by Git data model.

Thank you @yuja, the new `subject()` PR you created is great.

Just FYI, while adding a \n is not a big deal on Linux or macOS, it is in fact quite hard on Windows unless you use the new PowerShell as your shell. Both cmd.exe and the old PS make it harder than you'd think. On cmd.exe you need to double the double quotes (e.g. `jj log -r "description(exact:""test\n"")"`), while on the old (default) PS we did not find a good solution.

Because of that, in addition to the inconsistency that I mentioned I still think that it would make sense for `describe(exact:X)` to add the `\n` for you if needed, but the proposed `subject()` function makes this less of a priority.
> it would make sense for `describe(exact:X)` to add the `\n` for you if needed

Since the trailing newline is not part of the data model, there can exist commits without a trailing newline. Since both the git and jj CLIs seem to consistently enforce the trailing newline, perhaps such commits would only be created by tools using the git or jj libraries (libgit2, jgit, gitoxide, jj-lib, etc.). So if `describe(exact:X)` adds the newline, then it would make it impossible to search for commits without a trailing newline.

I understand that the current behavior is confusing, but hopefully `describe(exact:X)` is kind of rare in practice and would usually show up only in scripts?
It's also weird that only the `exact:` pattern is handled differently. Suppose many use cases of `describe(exact:"non-empty-string")` can be served by `subject()`, I wouldn't want to introduce weird special case. If it's super confusing, I would rather add a warning.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4b10fd6c3f..30478b2ce77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -107,6 +107,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 * New `author_name`/`author_email`/`committer_name`/`committer_email(pattern)`
   revset functions to match either name or email field explicitly.
 
+* New `subject(pattern)` revset function that matches first line of commit
+  descriptions.
+
 ### Fixed bugs
 
 * Fixed diff selection by external tools with `jj split`/`commit -i FILESETS`.
diff --git a/docs/revsets.md b/docs/revsets.md
index 556b4bc1946..810fefd5d53 100644
--- a/docs/revsets.md
+++ b/docs/revsets.md
@@ -267,6 +267,14 @@ revsets (expressions) as arguments.
 * `description(pattern)`: Commits that have a description matching the given
   [string pattern](#string-patterns).
 
+  A non-empty description is usually terminated with newline character. For
+  example, `description(exact:"")` matches commits without description, and
+  `description(exact:"foo\n")` matches commits with description `"foo\n"`.
+
+* `subject(pattern)`: Commits that have a subject matching the given [string
+  pattern](#string-patterns). A subject is the first line of the description
+  (without newline character.)
+
 * `author(pattern)`: Commits with the author's name or email matching the given
   [string pattern](#string-patterns). Equivalent to `author_name(pattern) |
   author_email(pattern)`.
diff --git a/lib/src/default_index/revset_engine.rs b/lib/src/default_index/revset_engine.rs
index 876e870eca6..80c95ebdeec 100644
--- a/lib/src/default_index/revset_engine.rs
+++ b/lib/src/default_index/revset_engine.rs
@@ -1161,6 +1161,14 @@ fn build_predicate_fn(
                 Ok(pattern.matches(commit.description()))
             })
         }
+        RevsetFilterPredicate::Subject(pattern) => {
+            let pattern = pattern.clone();
+            box_pure_predicate_fn(move |index, pos| {
+                let entry = index.entry_by_pos(pos);
+                let commit = store.get_commit(&entry.commit_id())?;
+                Ok(pattern.matches(commit.description().lines().next().unwrap_or_default()))
+            })
+        }
         RevsetFilterPredicate::AuthorName(pattern) => {
             let pattern = pattern.clone();
             box_pure_predicate_fn(move |index, pos| {
diff --git a/lib/src/revset.rs b/lib/src/revset.rs
index 27250684a5a..04ff8f9c8aa 100644
--- a/lib/src/revset.rs
+++ b/lib/src/revset.rs
@@ -156,6 +156,8 @@ pub enum RevsetFilterPredicate {
     ParentCount(Range<u32>),
     /// Commits with description matching the pattern.
     Description(StringPattern),
+    /// Commits with first line of the description matching the pattern.
+    Subject(StringPattern),
     /// Commits with author name matching the pattern.
     AuthorName(StringPattern),
     /// Commits with author email matching the pattern.
@@ -834,6 +836,12 @@ static BUILTIN_FUNCTION_MAP: Lazy<HashMap<&'static str, RevsetFunction>> = Lazy:
             RevsetFilterPredicate::Description(pattern),
         ))
     });
+    map.insert("subject", |diagnostics, function, _context| {
+        let [arg] = function.expect_exact_arguments()?;
+        let pattern = expect_string_pattern(diagnostics, arg)?;
+        let predicate = RevsetFilterPredicate::Subject(pattern);
+        Ok(RevsetExpression::filter(predicate))
+    });
     map.insert("author", |diagnostics, function, _context| {
         let [arg] = function.expect_exact_arguments()?;
         let pattern = expect_string_pattern(diagnostics, arg)?;


Test patch:
diff --git a/lib/tests/test_revset.rs b/lib/tests/test_revset.rs
index a728338c3c3..7ae3c7ae123 100644
--- a/lib/tests/test_revset.rs
+++ b/lib/tests/test_revset.rs
@@ -2654,17 +2654,17 @@ fn test_evaluate_expression_description() {
     let mut_repo = tx.repo_mut();
 
     let commit1 = create_random_commit(mut_repo)
-        .set_description("commit 1")
+        .set_description("commit 1\n")
         .write()
         .unwrap();
     let commit2 = create_random_commit(mut_repo)
         .set_parents(vec![commit1.id().clone()])
-        .set_description("commit 2")
+        .set_description("commit 2\n\nblah blah...\n")
         .write()
         .unwrap();
     let commit3 = create_random_commit(mut_repo)
         .set_parents(vec![commit2.id().clone()])
-        .set_description("commit 3")
+        .set_description("commit 3\n")
         .write()
         .unwrap();
 
@@ -2687,6 +2687,40 @@ fn test_evaluate_expression_description() {
         resolve_commit_ids(mut_repo, "visible_heads() & description(\"commit 2\")"),
         vec![]
     );
+
+    // Exact match
+    assert_eq!(
+        resolve_commit_ids(mut_repo, r#"description(exact:"commit 1\n")"#),
+        vec![commit1.id().clone()]
+    );
+    assert_eq!(
+        resolve_commit_ids(mut_repo, r#"description(exact:"commit 2\n")"#),
+        vec![]
+    );
+    assert_eq!(
+        resolve_commit_ids(mut_repo, "description(exact:'')"),
+        vec![mut_repo.store().root_commit_id().clone()]
+    );
+
+    // Match subject line
+    assert_eq!(
+        resolve_commit_ids(mut_repo, "subject(glob:'commit ?')"),
+        vec![
+            commit3.id().clone(),
+            commit2.id().clone(),
+            commit1.id().clone(),
+        ]
+    );
+    assert_eq!(resolve_commit_ids(mut_repo, "subject('blah')"), vec![]);
+    assert_eq!(
+        resolve_commit_ids(mut_repo, "subject(exact:'commit 2')"),
+        vec![commit2.id().clone()]
+    );
+    // Empty description should have empty subject line
+    assert_eq!(
+        resolve_commit_ids(mut_repo, "subject(exact:'')"),
+        vec![mut_repo.store().root_commit_id().clone()]
+    );
 }
 
 #[test]