// Copyright 2022 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 testutils::TestResult;
use testutils::git;

use crate::common::CommandOutput;
use crate::common::TestEnvironment;
use crate::common::TestWorkDir;

fn create_commit_with_refs(
    repo: &gix::Repository,
    message: &str,
    content: &[u8],
    ref_names: &[&str],
) {
    let git::CommitResult {
        tree_id: _,
        commit_id,
    } = git::add_commit(repo, "refs/heads/dummy", "file", content, message, &[]);
    repo.find_reference("dummy").unwrap().delete().unwrap();

    for name in ref_names {
        repo.reference(
            *name,
            commit_id,
            gix::refs::transaction::PreviousValue::Any,
            "log message",
        )
        .unwrap();
    }
}

#[test]
fn test_bookmark_multiple_names() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let output = work_dir.run_jj(["bookmark", "create", "-r@", "foo", "bar"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 2 bookmarks pointing to qpvuntsm e8849ae1 bar foo | (empty) (no description set)
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar foo e8849ae12c70
    ◆   000000000000
    [EOF]
    ");

    work_dir.run_jj(["new"]).success();
    let output = work_dir.run_jj(["bookmark", "set", "foo", "bar"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 2 bookmarks to zsuskuln 0e555a27 bar foo | (empty) (no description set)
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar foo 0e555a27ac99
    ○   e8849ae12c70
    ◆   000000000000
    [EOF]
    ");

    let output = work_dir.run_jj(["bookmark", "delete", "foo", "bar", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Deleted 2 bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   0e555a27ac99
    ○   e8849ae12c70
    ◆   000000000000
    [EOF]
    ");

    // Hint should be omitted if -r is specified
    let output = work_dir.run_jj(["bookmark", "create", "-r@-", "foo", "bar"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 2 bookmarks pointing to qpvuntsm e8849ae1 bar foo | (empty) (no description set)
    [EOF]
    ");

    // Create and move with explicit -r
    let output = work_dir.run_jj(["bookmark", "set", "-r@", "bar", "baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 1 bookmarks pointing to zsuskuln 0e555a27 bar baz | (empty) (no description set)
    Moved 1 bookmarks to zsuskuln 0e555a27 bar baz | (empty) (no description set)
    [EOF]
    ");

    // Noop changes should not be included in the stats
    let output = work_dir.run_jj(["bookmark", "set", "-r@", "foo", "bar", "baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to zsuskuln 0e555a27 bar baz foo | (empty) (no description set)
    [EOF]
    ");
}

#[test]
fn test_bookmark_at_root() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let output = work_dir.run_jj(["bookmark", "create", "fred", "-r=root()"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 1 bookmarks pointing to zzzzzzzz 00000000 fred | (empty) (no description set)
    [EOF]
    ");
    let output = work_dir.run_jj(["git", "export"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Nothing changed.
    Warning: Failed to export some bookmarks:
      fred@git: Ref cannot point to the root commit in Git
    [EOF]
    ");
}

#[test]
fn test_bookmark_bad_name() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let output = work_dir.run_jj(["bookmark", "create", ""]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '' for '<NAMES>...': Failed to parse bookmark name: Syntax error

    For more information, try '--help'.
    Caused by:  --> 1:1
      |
    1 | 
      | ^---
      |
      = expected <identifier>, <string_literal>, or <raw_string_literal>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for how to quote symbols.
    [EOF]
    [exit status: 2]
    ");

    let output = work_dir.run_jj(["bookmark", "set", "''"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '''' for '<NAMES>...': Failed to parse bookmark name: Expected non-empty string

    For more information, try '--help'.
    Caused by:  --> 1:1
      |
    1 | ''
      | ^^
      |
      = Expected non-empty string
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for how to quote symbols.
    [EOF]
    [exit status: 2]
    ");

    let output = work_dir.run_jj(["bookmark", "rename", "x", ""]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '' for '<NEW>': Failed to parse bookmark name: Syntax error

    For more information, try '--help'.
    Caused by:  --> 1:1
      |
    1 | 
      | ^---
      |
      = expected <identifier>, <string_literal>, or <raw_string_literal>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for how to quote symbols.
    [EOF]
    [exit status: 2]
    ");

    // common errors
    let output = work_dir.run_jj(["bookmark", "set", "@-", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '@-' for '<NAMES>...': Failed to parse bookmark name: Syntax error

    For more information, try '--help'.
    Caused by:  --> 1:1
      |
    1 | @-
      | ^---
      |
      = expected <identifier>, <string_literal>, or <raw_string_literal>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for how to quote symbols.
    [EOF]
    [exit status: 2]
    ");

    let stderr = work_dir.run_jj(["bookmark", "set", "-r@-", "foo@bar"]);
    insta::assert_snapshot!(stderr, @"
    ------- stderr -------
    error: invalid value 'foo@bar' for '<NAMES>...': Failed to parse bookmark name: Syntax error

    For more information, try '--help'.
    Caused by:  --> 1:4
      |
    1 | foo@bar
      |    ^---
      |
      = expected <EOI>
    Hint: Looks like remote bookmark. Run `jj bookmark track foo@bar` to track it.
    [EOF]
    [exit status: 2]
    ");

    // quoted name works
    let output = work_dir.run_jj(["bookmark", "create", "'foo@bar'"]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 1 bookmarks pointing to qpvuntsm e8849ae1 "foo@bar" | (empty) (no description set)
    [EOF]
    "#);
}

#[test]
fn test_bookmark_move() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up remote
    let git_repo_path = test_env.env_root().join("git-repo");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    let output = work_dir.run_jj(["bookmark", "move", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching bookmarks for names: foo
    No bookmarks to update.
    [EOF]
    ");

    let output = work_dir.run_jj(["bookmark", "set", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 1 bookmarks pointing to qpvuntsm e8849ae1 foo | (empty) (no description set)
    [EOF]
    ");

    work_dir.run_jj(["new"]).success();
    let output = work_dir.run_jj(["bookmark", "create", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Bookmark already exists: foo
    Hint: Use `jj bookmark set` to update it.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["bookmark", "set", "foo", "--revision", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to mzvwutvl 8afc18ff foo | (empty) (no description set)
    [EOF]
    ");

    let output = work_dir.run_jj(["bookmark", "set", "-r@-", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Refusing to move bookmark backwards or sideways: foo
    Hint: Use --allow-backwards to allow it.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["bookmark", "set", "-r@-", "--allow-backwards", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to qpvuntsm e8849ae1 foo | (empty) (no description set)
    [EOF]
    ");

    let output = work_dir.run_jj(["bookmark", "move", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to mzvwutvl 8afc18ff foo | (empty) (no description set)
    [EOF]
    ");

    let output = work_dir.run_jj(["bookmark", "move", "--to=@-", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Refusing to move bookmark backwards or sideways: foo
    Hint: Use --allow-backwards to allow it.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["bookmark", "move", "--to=@-", "--allow-backwards", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to qpvuntsm e8849ae1 foo | (empty) (no description set)
    [EOF]
    ");

    // Delete bookmark locally, but is still tracking remote
    work_dir.run_jj(["describe", "@-", "-mcommit"]).success();
    work_dir.run_jj(["git", "push", "--bookmark=foo"]).success();
    work_dir.run_jj(["bookmark", "delete", "foo"]).success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    foo (deleted)
      @origin: qpvuntsm 5f3ceb1e (empty) commit
    [EOF]
    ");

    // Deleted tracking bookmark name should still be allocated
    let output = work_dir.run_jj(["bookmark", "create", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Tracked remote bookmarks exist for deleted bookmark: foo
    Hint: Use `jj bookmark set` to recreate the local bookmark. Run `jj bookmark untrack foo` to disassociate them.
    [EOF]
    [exit status: 1]
    ");

    // Restoring local target shouldn't invalidate tracking state
    let output = work_dir.run_jj(["bookmark", "set", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Moved 1 bookmarks to mzvwutvl 91b59745 foo* | (empty) (no description set)
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    foo: mzvwutvl 91b59745 (empty) (no description set)
      @origin (behind by 1 commits): qpvuntsm 5f3ceb1e (empty) commit
    [EOF]
    ");

    // Untracked remote bookmark shouldn't block creation of local bookmark
    work_dir.run_jj(["bookmark", "untrack", "foo"]).success();
    work_dir.run_jj(["bookmark", "delete", "foo"]).success();
    let output = work_dir.run_jj(["bookmark", "create", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Created 1 bookmarks pointing to mzvwutvl 91b59745 foo | (empty) (no description set)
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    foo: mzvwutvl 91b59745 (empty) (no description set)
    foo@origin: qpvuntsm 5f3ceb1e (empty) commit
    [EOF]
    ");
}

#[test]
fn test_bookmark_move_matching() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["bookmark", "create", "a1", "a2"])
        .success();
    work_dir.run_jj(["new", "-mhead1"]).success();
    work_dir.run_jj(["new", "root()"]).success();
    work_dir.run_jj(["bookmark", "create", "b1"]).success();
    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["bookmark", "create", "c1"]).success();
    work_dir.run_jj(["new", "-mhead2"]).success();
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   0dd9a4b12283
    ○  c1 2cbf65662e56
    ○  b1 c2934cfbfb19
    │ ○   9328ecc52471
    │ ○  a1 a2 e8849ae12c70
    ├─╯
    ◆   000000000000
    [EOF]
    ");
    let setup_opid = work_dir.current_operation_id();

    // The default could be considered "--from=all() *", but is disabled
    let output = work_dir.run_jj(["bookmark", "move"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: the following required arguments were not provided:
      <NAMES|--from <REVSETS>>

    Usage: jj bookmark move <NAMES|--from <REVSETS>>

    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");

    // No bookmarks pointing to the source revisions
    let output = work_dir.run_jj(["bookmark", "move", "--from=none()"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    No bookmarks to update.
    [EOF]
    ");

    // No matching bookmarks within the source revisions
    let output = work_dir.run_jj(["bookmark", "move", "--from=::@", "'a?'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    No bookmarks to update.
    [EOF]
    ");

    // Noop move
    let output = work_dir.run_jj(["bookmark", "move", "--to=a1", "a2"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    No bookmarks to update.
    [EOF]
    ");

    // Move from multiple revisions
    let output = work_dir.run_jj(["bookmark", "move", "--from=::@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Moved 2 bookmarks to vruxwmqv 0dd9a4b1 b1 c1 | (empty) head2
    Hint: Specify bookmark by name to update just one of the bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  b1 c1 0dd9a4b12283
    ○   2cbf65662e56
    ○   c2934cfbfb19
    │ ○   9328ecc52471
    │ ○  a1 a2 e8849ae12c70
    ├─╯
    ◆   000000000000
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Move multiple bookmarks by name
    let output = work_dir.run_jj(["bookmark", "move", "b1", "c1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Moved 2 bookmarks to vruxwmqv 0dd9a4b1 b1 c1 | (empty) head2
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  b1 c1 0dd9a4b12283
    ○   2cbf65662e56
    ○   c2934cfbfb19
    │ ○   9328ecc52471
    │ ○  a1 a2 e8849ae12c70
    ├─╯
    ◆   000000000000
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Try to move multiple bookmarks, but one of them isn't fast-forward
    let output = work_dir.run_jj(["bookmark", "move", "'?1'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Refusing to move bookmark backwards or sideways: a1
    Hint: Use --allow-backwards to allow it.
    [EOF]
    [exit status: 1]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   0dd9a4b12283
    ○  c1 2cbf65662e56
    ○  b1 c2934cfbfb19
    │ ○   9328ecc52471
    │ ○  a1 a2 e8849ae12c70
    ├─╯
    ◆   000000000000
    [EOF]
    ");

    // Select by revision and name
    let output = work_dir.run_jj(["bookmark", "move", "--from=::a1+", "--to=a1+", "'?1'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Moved 1 bookmarks to kkmpptxz 9328ecc5 a1 | (empty) head1
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   0dd9a4b12283
    ○  c1 2cbf65662e56
    ○  b1 c2934cfbfb19
    │ ○  a1 9328ecc52471
    │ ○  a2 e8849ae12c70
    ├─╯
    ◆   000000000000
    [EOF]
    ");
}

#[test]
fn test_bookmark_move_conflicting() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let get_log = || {
        let template = r#"separate(" ", description.first_line(), bookmarks)"#;
        work_dir.run_jj(["log", "-T", template])
    };

    work_dir.run_jj(["new", "root()", "-mA0"]).success();
    work_dir.run_jj(["new", "root()", "-mB0"]).success();
    work_dir.run_jj(["new", "root()", "-mC0"]).success();
    work_dir.run_jj(["new", "subject(A0)", "-mA1"]).success();

    // Set up conflicting bookmark.
    work_dir
        .run_jj(["bookmark", "create", "-rsubject(A0)", "foo"])
        .success();
    work_dir
        .run_jj(["bookmark", "create", "--at-op=@-", "-rsubject(B0)", "foo"])
        .success();
    insta::assert_snapshot!(get_log(), @"
    @  A1
    ○  A0 foo??
    │ ○  C0
    ├─╯
    │ ○  B0 foo??
    ├─╯
    ◆
    [EOF]
    ------- stderr -------
    Concurrent modification detected, resolving automatically.
    [EOF]
    ");

    // Can't move the bookmark to C0 since it's sibling.
    let output = work_dir.run_jj(["bookmark", "set", "-rsubject(C0)", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Refusing to move bookmark backwards or sideways: foo
    Hint: Use --allow-backwards to allow it.
    [EOF]
    [exit status: 1]
    ");

    // Can move the bookmark to A1 since it's descendant of A0. It's not
    // descendant of B0, though.
    let output = work_dir.run_jj(["bookmark", "set", "-rsubject(A1)", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Moved 1 bookmarks to mzvwutvl 0f5f3e2c foo | (empty) A1
    [EOF]
    ");
    insta::assert_snapshot!(get_log(), @"
    @  A1 foo
    ○  A0
    │ ○  C0
    ├─╯
    │ ○  B0
    ├─╯
    ◆
    [EOF]
    ");
}

#[test]
fn test_bookmark_rename() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up remote
    let git_repo_path = test_env.env_root().join("git-repo");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    let output = work_dir.run_jj(["bookmark", "rename", "bnoexist", "blocal"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: No such bookmark: bnoexist
    [EOF]
    [exit status: 1]
    ");

    work_dir.run_jj(["describe", "-m=commit-0"]).success();
    work_dir.run_jj(["bookmark", "create", "blocal"]).success();
    let output = work_dir.run_jj(["bookmark", "rename", "blocal", "blocal1"]);
    insta::assert_snapshot!(output, @"");

    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["describe", "-m=commit-1"]).success();
    work_dir.run_jj(["bookmark", "create", "bexist"]).success();
    let output = work_dir.run_jj(["bookmark", "rename", "blocal1", "bexist"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Bookmark already exists: bexist
    [EOF]
    [exit status: 1]
    ");

    // Rename to the same name is an error (bookmark already exists)
    let output = work_dir.run_jj(["bookmark", "rename", "blocal1", "blocal1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Bookmark already exists: blocal1
    [EOF]
    [exit status: 1]
    ");

    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["describe", "-m=commit-2"]).success();
    work_dir
        .run_jj(["bookmark", "create", "bremote", "buntracked"])
        .success();
    work_dir
        .run_jj(["git", "push", "-b=bremote", "-b=buntracked"])
        .success();

    let output = work_dir.run_jj(["bookmark", "rename", "bremote", "bremote2"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracked remote bookmarks for bookmark bremote were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark bremote` first (to delete it on the remote), and then `jj git push --bookmark bremote2`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");
    let op_id_after_rename = work_dir.current_operation_id();
    let output = work_dir.run_jj(["bookmark", "rename", "bremote2", "bremote"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracked remote bookmarks for bookmark bremote exist.
    Hint: Run `jj bookmark untrack bremote` to disassociate them.
    [EOF]
    ");
    work_dir
        .run_jj(["op", "restore", &op_id_after_rename])
        .success();
    let output = work_dir.run_jj(["git", "push", "--bookmark", "bremote2"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Changes to push to origin:
      bookmark: bremote2 [add to 1e76d54fcfce]
    [EOF]
    ");
    work_dir
        .run_jj(["git", "push", "--named", "bremote-untracked=@"])
        .success();
    work_dir
        .run_jj(["bookmark", "forget", "bremote-untracked"])
        .success();
    let output = work_dir.run_jj(["bookmark", "rename", "bremote2", "bremote-untracked"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: The renamed bookmark already exists on the remote 'origin', tracking state was dropped.
    Hint: To track the existing remote bookmark, run `jj bookmark track bremote-untracked@origin`.
    Warning: Tracked remote bookmarks for bookmark bremote2 were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark bremote2` first (to delete it on the remote), and then `jj git push --bookmark bremote-untracked`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");

    // rename an untracked bookmark
    work_dir
        .run_jj(["bookmark", "untrack", "buntracked"])
        .success();
    let output = work_dir.run_jj(["bookmark", "rename", "buntracked", "buntracked2"]);
    insta::assert_snapshot!(output, @"");
}

#[test]
fn test_bookmark_rename_overwrite() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up commits
    work_dir.run_jj(["describe", "-m=DST"]).success();
    work_dir.run_jj(["bookmark", "create", "DST"]).success();
    work_dir.run_jj(["new", "root()", "-m=SRC"]).success();
    work_dir.run_jj(["bookmark", "create", "SRC"]).success();

    // Set up remote
    let remote_path = test_env.env_root().join("git-repo");
    git::init_bare(remote_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    // Construct old bookmark state
    work_dir
        .run_jj(["bookmark", "create", "-r=SRC", "a-absent-tracked"])
        .success();
    work_dir
        .run_jj([
            "git",
            "push",
            "--named=a-present-tracked=SRC",
            "--named=a-present-untracked=SRC",
        ])
        .success();
    work_dir
        .run_jj(["bookmark", "track", "*-tracked", "--remote=origin"])
        .success();
    work_dir
        .run_jj(["bookmark", "untrack", "*-untracked"])
        .success();
    let setup_opid = work_dir.current_operation_id();

    // Overwriting an existing bookmark with itself is a no-op
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-tracked",
        "a-present-tracked",
    ]);
    insta::assert_snapshot!(output, @"");

    // Overwrite absent, tracked bookmarks
    work_dir
        .run_jj([
            "bookmark",
            "create",
            "-r=SRC",
            "b-absent-tracked",
            "c-absent-tracked",
            "d-absent-tracked",
        ])
        .success();
    work_dir
        .run_jj(["bookmark", "track", "*-tracked", "--remote=origin"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    a-present-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    c-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    d-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-absent-tracked",
        "b-absent-tracked",
    ]);
    insta::assert_snapshot!(output, @"");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-tracked",
        "c-absent-tracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracked remote bookmarks for bookmark a-present-tracked were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark a-present-tracked` first (to delete it on the remote), and then `jj git push --bookmark c-absent-tracked`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-untracked",
        "d-absent-tracked",
    ]);
    insta::assert_snapshot!(output, @"");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-present-tracked (deleted)
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    c-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    d-absent-tracked: zsuskuln 1acdc981 (empty) SRC
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Overwrite absent, untracked (non-existent) bookmarks
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    a-present-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-absent-tracked",
        "b-absent-untracked",
    ]);
    insta::assert_snapshot!(output, @"");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-tracked",
        "c-absent-untracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracked remote bookmarks for bookmark a-present-tracked were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark a-present-tracked` first (to delete it on the remote), and then `jj git push --bookmark c-absent-untracked`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-untracked",
        "d-absent-untracked",
    ]);
    insta::assert_snapshot!(output, @"");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-present-tracked (deleted)
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-absent-untracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    c-absent-untracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    d-absent-untracked: zsuskuln 1acdc981 (empty) SRC
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Overwrite present, tracked bookmarks
    work_dir
        .run_jj([
            "git",
            "push",
            "--named=b-present-tracked=DST",
            "--named=c-present-tracked=DST",
            "--named=d-present-tracked=DST",
        ])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    a-present-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-present-tracked: qpvuntsm b3bfa1df (empty) DST
      @origin: qpvuntsm b3bfa1df (empty) DST
    c-present-tracked: qpvuntsm b3bfa1df (empty) DST
      @origin: qpvuntsm b3bfa1df (empty) DST
    d-present-tracked: qpvuntsm b3bfa1df (empty) DST
      @origin: qpvuntsm b3bfa1df (empty) DST
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-absent-tracked",
        "b-present-tracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: The renamed bookmark already exists on the remote 'origin', tracking state was dropped.
    Hint: To track the existing remote bookmark, run `jj bookmark track b-present-tracked@origin`.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-tracked",
        "c-present-tracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: The renamed bookmark already exists on the remote 'origin', tracking state was dropped.
    Hint: To track the existing remote bookmark, run `jj bookmark track c-present-tracked@origin`.
    Warning: Tracked remote bookmarks for bookmark a-present-tracked were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark a-present-tracked` first (to delete it on the remote), and then `jj git push --bookmark c-present-tracked`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-untracked",
        "d-present-tracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracking of remote bookmark d-present-tracked@origin was dropped.
    Hint: Use `jj bookmark track` to re-track if needed.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-present-tracked (deleted)
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-present-tracked: zsuskuln 1acdc981 (empty) SRC
    b-present-tracked@origin: qpvuntsm b3bfa1df (empty) DST
    c-present-tracked: zsuskuln 1acdc981 (empty) SRC
    c-present-tracked@origin: qpvuntsm b3bfa1df (empty) DST
    d-present-tracked: zsuskuln 1acdc981 (empty) SRC
    d-present-tracked@origin: qpvuntsm b3bfa1df (empty) DST
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Overwrite present, untracked bookmarks
    work_dir
        .run_jj([
            "git",
            "push",
            "--named=b-present-untracked=DST",
            "--named=c-present-untracked=DST",
            "--named=d-present-untracked=DST",
        ])
        .success();
    work_dir
        .run_jj(["bookmark", "untrack", "*-untracked"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-absent-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin (not created yet)
    a-present-tracked: zsuskuln 1acdc981 (empty) SRC
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-present-untracked: qpvuntsm b3bfa1df (empty) DST
    b-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    c-present-untracked: qpvuntsm b3bfa1df (empty) DST
    c-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    d-present-untracked: qpvuntsm b3bfa1df (empty) DST
    d-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-absent-tracked",
        "b-present-untracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: The renamed bookmark already exists on the remote 'origin', tracking state was dropped.
    Hint: To track the existing remote bookmark, run `jj bookmark track b-present-untracked@origin`.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-tracked",
        "c-present-untracked",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: The renamed bookmark already exists on the remote 'origin', tracking state was dropped.
    Hint: To track the existing remote bookmark, run `jj bookmark track c-present-untracked@origin`.
    Warning: Tracked remote bookmarks for bookmark a-present-tracked were not renamed.
    Hint: To rename the bookmark on the remote, you can `jj git push --bookmark a-present-tracked` first (to delete it on the remote), and then `jj git push --bookmark c-present-untracked`. `jj git push --all --deleted` would also be sufficient.
    [EOF]
    ");
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "a-present-untracked",
        "d-present-untracked",
    ]);
    insta::assert_snapshot!(output, @"");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    DST: qpvuntsm b3bfa1df (empty) DST
    SRC: zsuskuln 1acdc981 (empty) SRC
    a-present-tracked (deleted)
      @origin: zsuskuln 1acdc981 (empty) SRC
    a-present-untracked@origin: zsuskuln 1acdc981 (empty) SRC
    b-present-untracked: zsuskuln 1acdc981 (empty) SRC
    b-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    c-present-untracked: zsuskuln 1acdc981 (empty) SRC
    c-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    d-present-untracked: zsuskuln 1acdc981 (empty) SRC
    d-present-untracked@origin: qpvuntsm b3bfa1df (empty) DST
    [EOF]
    ");
}

#[test]
fn test_bookmark_rename_colocated() {
    let test_env = TestEnvironment::default();
    test_env
        .run_jj_in(".", ["git", "init", "repo", "--colocate"])
        .success();
    let work_dir = test_env.work_dir("repo");

    work_dir.run_jj(["describe", "-m=commit-0"]).success();
    work_dir.run_jj(["bookmark", "create", "blocal"]).success();

    // Make sure that git tracking bookmarks don't cause a warning
    let output = work_dir.run_jj(["bookmark", "rename", "blocal", "blocal1"]);
    insta::assert_snapshot!(output, @"");

    // overwrite-existing in a colocated repo with a real remote:
    // @git is filtered out by remote_matcher, but @origin should be handled.
    let git_repo_path = test_env.env_root().join("git-repo");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["describe", "-m=commit-1"]).success();
    work_dir.run_jj(["bookmark", "create", "bpushed"]).success();
    work_dir.run_jj(["git", "push", "-b=bpushed"]).success();
    // blocal1 is local-only (no tracked @origin). Overwriting bpushed should
    // warn about bpushed@origin tracking being dropped, while @git stays quiet.
    let output = work_dir.run_jj([
        "bookmark",
        "rename",
        "--overwrite-existing",
        "blocal1",
        "bpushed",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Tracking of remote bookmark bpushed@origin was dropped.
    Hint: Use `jj bookmark track` to re-track if needed.
    Warning: The working-copy commit became immutable; a new commit has been created on top of it.
    Working copy  (@) now at: znkkpsqq cf8db4ba (empty) (no description set)
    Parent commit (@-)      : royxmykx b6e46c10 bpushed@origin | (empty) commit-1
    [EOF]
    ");
    let output = work_dir.run_jj(["bookmark", "list", "--all", "bpushed"]);
    insta::assert_snapshot!(output, @"
    bpushed: qpvuntsm f18f73f2 (empty) commit-0
      @git: qpvuntsm f18f73f2 (empty) commit-0
    bpushed@origin: royxmykx b6e46c10 (empty) commit-1
    [EOF]
    ");
}

#[test]
fn test_bookmark_forget_glob() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir.run_jj(["bookmark", "create", "foo-1"]).success();
    work_dir.run_jj(["bookmark", "create", "bar-2"]).success();
    work_dir.run_jj(["bookmark", "create", "foo-3"]).success();
    work_dir.run_jj(["bookmark", "create", "foo-4"]).success();
    let setup_opid = work_dir.current_operation_id();

    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 foo-1 foo-3 foo-4 e8849ae12c70
    ◆   000000000000
    [EOF]
    ");
    let output = work_dir.run_jj(["bookmark", "forget", "'foo-[1-3]'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 2 local bookmarks.
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();
    let output = work_dir.run_jj(["bookmark", "forget", "'foo-[1-3]'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 2 local bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 foo-4 e8849ae12c70
    ◆   000000000000
    [EOF]
    ");

    // Forgetting a bookmark via both explicit name and glob pattern, or with
    // multiple glob patterns, shouldn't produce an error.
    let output = work_dir.run_jj(["bookmark", "forget", "foo-4", "foo-*", "foo-*"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 1 local bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 e8849ae12c70
    ◆   000000000000
    [EOF]
    ");

    // Malformed glob
    let output = work_dir.run_jj(["bookmark", "forget", "glob:'foo-[1-3'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to parse name pattern: Invalid string pattern
    Caused by:
    1:  --> 1:1
      |
    1 | glob:'foo-[1-3'
      | ^-------------^
      |
      = Invalid string pattern
    2: error parsing glob 'foo-[1-3': unclosed character class; missing ']'
    [EOF]
    [exit status: 1]
    ");

    // None of the globs match anything
    let output = work_dir.run_jj(["bookmark", "forget", "baz*", "boom*"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    No bookmarks to forget.
    [EOF]
    ");
}

#[test]
fn test_bookmark_delete_glob() -> TestResult {
    // Set up a git repo with a bookmark and a jj repo that has it as a remote.
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init_bare(git_repo_path);
    let blob_oid = git_repo.write_blob(b"content")?;
    let mut tree_editor =
        git_repo.edit_tree(gix::ObjectId::empty_tree(gix::hash::Kind::default()))?;
    tree_editor.upsert("file", gix::object::tree::EntryKind::Blob, blob_oid)?;
    let _tree_id = tree_editor.write()?;
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    work_dir.run_jj(["describe", "-m=commit"]).success();
    work_dir.run_jj(["bookmark", "create", "foo-1"]).success();
    work_dir.run_jj(["bookmark", "create", "bar-2"]).success();
    work_dir.run_jj(["bookmark", "create", "foo-3"]).success();
    work_dir.run_jj(["bookmark", "create", "foo-4"]).success();
    // Push to create remote-tracking bookmarks
    work_dir.run_jj(["git", "push", "--all"]).success();
    // Add absent-tracked bookmark
    work_dir.run_jj(["bookmark", "create", "foo-5"]).success();
    work_dir.run_jj(["bookmark", "track", "foo-5"]).success();
    let setup_opid = work_dir.current_operation_id();

    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 foo-1 foo-3 foo-4 foo-5* 8e056f6b8c37
    ◆   000000000000
    [EOF]
    ");
    let output = work_dir.run_jj(["bookmark", "delete", "'foo-[1-3]'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Deleted 2 bookmarks.
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();
    let output = work_dir.run_jj(["bookmark", "delete", "'foo-[1-3]'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Deleted 2 bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 foo-1@origin foo-3@origin foo-4 foo-5* 8e056f6b8c37
    ◆   000000000000
    [EOF]
    ");

    // We get an error if none of the globs match live bookmarks. Unlike `jj
    // bookmark forget`, it's not allowed to delete already deleted bookmarks.
    let output = work_dir.run_jj(["bookmark", "delete", "'foo-[1-3]'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    No bookmarks to delete.
    [EOF]
    ");

    // Deleting a bookmark via both explicit name and glob pattern, or with
    // multiple glob patterns, shouldn't produce an error.
    let output = work_dir.run_jj(["bookmark", "delete", "foo-4", "foo-*", "foo-*"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Deleted 2 bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @  bar-2 foo-1@origin foo-3@origin foo-4@origin 8e056f6b8c37
    ◆   000000000000
    [EOF]
    ");

    // The deleted bookmarks are still there, whereas absent-tracked bookmarks
    // aren't.
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    bar-2: qpvuntsm 8e056f6b (empty) commit
      @origin: qpvuntsm 8e056f6b (empty) commit
    foo-1 (deleted)
      @origin: qpvuntsm 8e056f6b (empty) commit
    foo-3 (deleted)
      @origin: qpvuntsm 8e056f6b (empty) commit
    foo-4 (deleted)
      @origin: qpvuntsm 8e056f6b (empty) commit
    [EOF]
    ");

    // Malformed glob
    let output = work_dir.run_jj(["bookmark", "delete", "glob:'foo-[1-3'"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to parse name pattern: Invalid string pattern
    Caused by:
    1:  --> 1:1
      |
    1 | glob:'foo-[1-3'
      | ^-------------^
      |
      = Invalid string pattern
    2: error parsing glob 'foo-[1-3': unclosed character class; missing ']'
    [EOF]
    [exit status: 1]
    ");

    // Unknown pattern kind
    let output = work_dir.run_jj(["bookmark", "forget", "whatever:bookmark"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to parse name pattern: Invalid string pattern
    Caused by:
    1:  --> 1:1
      |
    1 | whatever:bookmark
      | ^---------------^
      |
      = Invalid string pattern
    2: Invalid string pattern kind `whatever:`
    Hint: Try prefixing with one of `exact:`, `glob:`, `regex:`, `substring:`, or one of these with `-i` suffix added (e.g. `glob-i:`) for case-insensitive matching
    [EOF]
    [exit status: 1]
    ");
    Ok(())
}

#[test]
fn test_bookmark_delete_export() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["bookmark", "create", "foo"]).success();
    work_dir.run_jj(["git", "export"]).success();

    work_dir.run_jj(["bookmark", "delete", "foo"]).success();
    let output = work_dir.run_jj(["bookmark", "list", "--all-remotes"]);
    insta::assert_snapshot!(output, @"
    foo (deleted)
      @git: rlvkpnrz 43444d88 (empty) (no description set)
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted will be deleted from the underlying Git repo on the next `jj git export`.
    [EOF]
    ");

    work_dir.run_jj(["git", "export"]).success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");
}

#[test]
fn test_bookmark_forget_export() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir.run_jj(["new"]).success();
    work_dir.run_jj(["bookmark", "create", "foo"]).success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    foo: rlvkpnrz 43444d88 (empty) (no description set)
    [EOF]
    ");

    // Exporting the bookmark to git creates a local-git tracking bookmark
    let output = work_dir.run_jj(["git", "export"]);
    insta::assert_snapshot!(output, @"");
    let output = work_dir.run_jj(["bookmark", "forget", "--include-remotes", "foo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 1 local bookmarks.
    Forgot 1 remote bookmarks.
    [EOF]
    ");
    // Forgetting a bookmark with --include-remotes deletes local and
    // remote-tracking bookmarks including the corresponding git-tracking bookmark.
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");
    let output = work_dir.run_jj(["log", "-r=foo", "--no-graph"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Revision `foo` doesn't exist
    [EOF]
    [exit status: 1]
    ");

    // `jj git export` will delete the bookmark from git. In a colocated
    // workspace, this will happen automatically immediately after a `jj bookmark
    // forget`. This is demonstrated in `test_git_colocated_bookmark_forget` in
    // test_git_colocated.rs
    let output = work_dir.run_jj(["git", "export"]);
    insta::assert_snapshot!(output, @"");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");
}

#[test]
fn test_bookmark_forget_fetched_bookmark() {
    // Much of this test is borrowed from `test_git_fetch_remote_only_bookmark` in
    // test_git_fetch.rs

    // Set up a git repo with a bookmark and a jj repo that has it as a remote.
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();
    // Create a commit and a bookmark in the git repo
    let git::CommitResult {
        tree_id,
        commit_id: first_git_repo_commit,
    } = git::add_commit(
        &git_repo,
        "refs/heads/feature1",
        "file",
        b"content",
        "message",
        &[],
    );

    // Fetch normally
    work_dir
        .run_jj(["git", "fetch", "--remote=origin"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qomsplrm ebeb70d8 message
      @origin: qomsplrm ebeb70d8 message
    [EOF]
    ");

    // TEST 1: with export-import
    // Forget the bookmark with --include-remotes
    work_dir
        .run_jj(["bookmark", "forget", "--include-remotes", "feature1"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");

    // At this point `jj git export && jj git import` does *not* recreate the
    // bookmark. This behavior is important in colocated workspaces, as otherwise a
    // forgotten bookmark would be immediately resurrected.
    //
    // Technically, this is because `jj bookmark forget` preserved
    // the ref in jj view's `git_refs` tracking the local git repo's remote-tracking
    // bookmark.
    // TODO: Show that jj git push is also a no-op
    let output = work_dir.run_jj(["git", "export"]);
    insta::assert_snapshot!(output, @"");
    let output = work_dir.run_jj(["git", "import"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Nothing changed.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");

    // We can fetch feature1 again.
    let output = work_dir.run_jj(["git", "fetch", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [new] tracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qomsplrm ebeb70d8 message
      @origin: qomsplrm ebeb70d8 message
    [EOF]
    ");

    // TEST 2: No export/import (otherwise the same as test 1)
    work_dir
        .run_jj(["bookmark", "forget", "--include-remotes", "feature1"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");
    // Fetch works even without the export-import
    let output = work_dir.run_jj(["git", "fetch", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [new] tracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qomsplrm ebeb70d8 message
      @origin: qomsplrm ebeb70d8 message
    [EOF]
    ");

    // TEST 3: fetch bookmark that was moved & forgotten with --include-remotes

    // Move the bookmark in the git repo.
    git::write_commit(
        &git_repo,
        "refs/heads/feature1",
        tree_id,
        "another message",
        &[first_git_repo_commit],
    );
    let output = work_dir.run_jj(["bookmark", "forget", "--include-remotes", "feature1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 1 local bookmarks.
    Forgot 1 remote bookmarks.
    [EOF]
    ");

    // Fetching a moved bookmark does not create a conflict
    let output = work_dir.run_jj(["git", "fetch", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [new] tracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: tyvxnvqr 9175cb32 (empty) another message
      @origin: tyvxnvqr 9175cb32 (empty) another message
    [EOF]
    ");

    // TEST 4: If `--include-remotes` isn't used, remote bookmarks are untracked
    work_dir
        .run_jj(["bookmark", "forget", "feature1"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1@origin: tyvxnvqr 9175cb32 (empty) another message
    [EOF]
    ");
    // There should be no output here since the remote bookmark wasn't forgotten
    let output = work_dir.run_jj(["git", "fetch", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Nothing changed.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1@origin: tyvxnvqr 9175cb32 (empty) another message
    [EOF]
    ");
}

#[test]
fn test_bookmark_forget_deleted_or_nonexistent_bookmark() {
    // Much of this test is borrowed from `test_git_fetch_remote_only_bookmark` in
    // test_git_fetch.rs

    // ======== Beginning of test setup ========
    // Set up a git repo with a bookmark and a jj repo that has it as a remote.
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init_bare(git_repo_path);
    // Create a commit and a bookmark in the git repo
    git::add_commit(
        &git_repo,
        "refs/heads/feature1",
        "file",
        b"content",
        "message",
        &[],
    );
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    // Fetch and then delete the bookmark
    work_dir
        .run_jj(["git", "fetch", "--remote=origin"])
        .success();
    work_dir
        .run_jj(["bookmark", "delete", "feature1"])
        .success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1 (deleted)
      @origin: qomsplrm ebeb70d8 message
    [EOF]
    ");

    // ============ End of test setup ============

    // We can forget a deleted bookmark. The local bookmark was already absent,
    // so only the remote count is reported.
    let output = work_dir.run_jj(["bookmark", "forget", "--include-remotes", "feature1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Forgot 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"");

    // Can't forget a non-existent bookmark
    let output = work_dir.run_jj(["bookmark", "forget", "i_do_not_exist"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching bookmarks for names: i_do_not_exist
    No bookmarks to forget.
    [EOF]
    ");
}

#[test]
fn test_bookmark_forget_untracked_remote_only_bookmark() {
    // Regression test for https://github.com/jj-vcs/jj/issues/9181:
    // `jj bookmark forget` on a remote-only, untracked bookmark used to print
    // a contradictory "Forgot 1 local bookmarks." line followed by "Nothing
    // changed." Now neither line is printed since nothing was forgotten.
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init_bare(git_repo_path);
    git::add_commit(
        &git_repo,
        "refs/heads/feature1",
        "file",
        b"content",
        "message",
        &[],
    );
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    // Fetch without auto-tracking, so feature1@origin is untracked and there
    // is no local feature1 bookmark.
    let output = work_dir.run_jj(["git", "fetch", "--remote=origin"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    bookmark: feature1@origin [new] untracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @r"
    feature1@origin: qomsplrm ebeb70d8 message
    [EOF]
    ");

    // Without `--include-remotes`, forgetting an untracked remote-only
    // bookmark is a no-op. We should not claim any local bookmarks were
    // forgotten.
    let output = work_dir.run_jj(["bookmark", "forget", "feature1"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Nothing changed.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @r"
    feature1@origin: qomsplrm ebeb70d8 message
    [EOF]
    ");
}

#[test]
fn test_bookmark_track_untrack() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up remote
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    // Fetch new commit without auto tracking. No local bookmarks should be
    // created.
    create_commit_with_refs(
        &git_repo,
        "commit 1",
        b"content 1",
        &[
            "refs/heads/main",
            "refs/heads/feature1",
            "refs/heads/feature2",
        ],
    );
    test_env.add_config("remotes.origin.auto-track-bookmarks = '~*'");
    let output = work_dir.run_jj(["git", "fetch"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [new] untracked
    bookmark: feature2@origin [new] untracked
    bookmark: main@origin     [new] untracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1@origin: qxxqrkql bd843888 commit 1
    feature2@origin: qxxqrkql bd843888 commit 1
    main@origin: qxxqrkql bd843888 commit 1
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   e8849ae12c70
    │ ◆  feature1@origin feature2@origin main@origin bd843888ee66
    ├─╯
    ◆   000000000000
    [EOF]
    ");

    // Track new bookmark. Local bookmark should be created.
    let output = work_dir.run_jj([
        "bookmark",
        "track",
        "feature1@origin",
        "main@origin",
        "nonexistent@origin",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching remote bookmarks for names: nonexistent@origin
    Started tracking 2 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qxxqrkql bd843888 commit 1
      @origin: qxxqrkql bd843888 commit 1
    feature2@origin: qxxqrkql bd843888 commit 1
    main: qxxqrkql bd843888 commit 1
      @origin: qxxqrkql bd843888 commit 1
    [EOF]
    ");

    // Track non-existent remote bookmark
    let output = work_dir.run_jj(["bookmark", "track", "feature3", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching bookmarks for names: feature3
    Nothing changed.
    [EOF]
    ");

    // Track existing bookmark. Local bookmark should result in conflict.
    work_dir
        .run_jj(["bookmark", "create", "-r@", "feature2"])
        .success();
    work_dir.run_jj(["bookmark", "track", "feature2"]).success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qxxqrkql bd843888 commit 1
      @origin: qxxqrkql bd843888 commit 1
    feature2 (conflicted):
      + qpvuntsm e8849ae1 (empty) (no description set)
      + qxxqrkql bd843888 commit 1
      @origin (behind by 1 commits): qxxqrkql bd843888 commit 1
    main: qxxqrkql bd843888 commit 1
      @origin: qxxqrkql bd843888 commit 1
    [EOF]
    ");

    // Untrack existing and locally-deleted bookmarks. Bookmark targets should be
    // unchanged
    work_dir
        .run_jj(["bookmark", "delete", "feature2"])
        .success();
    let output = work_dir.run_jj(["bookmark", "untrack", "feature1@origin", "feature2@origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Stopped tracking 2 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qxxqrkql bd843888 commit 1
    feature1@origin: qxxqrkql bd843888 commit 1
    feature2@origin: qxxqrkql bd843888 commit 1
    main: qxxqrkql bd843888 commit 1
      @origin: qxxqrkql bd843888 commit 1
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   e8849ae12c70
    │ ◆  feature1 feature1@origin feature2@origin main bd843888ee66
    ├─╯
    ◆   000000000000
    [EOF]
    ");

    // Fetch new commit. Only tracking bookmark "main" should be merged.
    create_commit_with_refs(
        &git_repo,
        "commit 2",
        b"content 2",
        &[
            "refs/heads/main",
            "refs/heads/feature1",
            "refs/heads/feature2",
        ],
    );
    let output = work_dir.run_jj(["git", "fetch"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [updated] untracked
    bookmark: feature2@origin [updated] untracked
    bookmark: main@origin     [updated] tracked
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qxxqrkql bd843888 commit 1
    feature1@origin: psynomvr 48ec79a4 commit 2
    feature2@origin: psynomvr 48ec79a4 commit 2
    main: psynomvr 48ec79a4 commit 2
      @origin: psynomvr 48ec79a4 commit 2
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   e8849ae12c70
    │ ◆  feature1@origin feature2@origin main 48ec79a430e9
    ├─╯
    │ ○  feature1 bd843888ee66
    ├─╯
    ◆   000000000000
    [EOF]
    ");

    // Fetch new commit with auto tracking. Tracking bookmark "main" and new
    // bookmark "feature3" should be merged.
    create_commit_with_refs(
        &git_repo,
        "commit 3",
        b"content 3",
        &[
            "refs/heads/main",
            "refs/heads/feature1",
            "refs/heads/feature2",
            "refs/heads/feature3",
        ],
    );
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
    let output = work_dir.run_jj(["git", "fetch"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [updated] untracked
    bookmark: feature2@origin [updated] untracked
    bookmark: feature3@origin [new] tracked
    bookmark: main@origin     [updated] tracked
    Abandoned 1 commits that are no longer reachable:
      psynomvr 48ec79a4 commit 2
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: qxxqrkql bd843888 commit 1
    feature1@origin: yumopmsr d8cd3e02 commit 3
    feature2@origin: yumopmsr d8cd3e02 commit 3
    feature3: yumopmsr d8cd3e02 commit 3
      @origin: yumopmsr d8cd3e02 commit 3
    main: yumopmsr d8cd3e02 commit 3
      @origin: yumopmsr d8cd3e02 commit 3
    [EOF]
    ");
    insta::assert_snapshot!(get_log_output(&work_dir), @"
    @   e8849ae12c70
    │ ◆  feature1@origin feature2@origin feature3 main d8cd3e020382
    ├─╯
    │ ○  feature1 bd843888ee66
    ├─╯
    ◆   000000000000
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_bookmark_track_conflict() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // add three remotes
    let git_repo_path = test_env.env_root().join("git-repo");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();
    let git_repo_path = test_env.env_root().join("git-repo2");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin2", "../git-repo2"])
        .success();
    let git_repo_path = test_env.env_root().join("git-repo3");
    git::init_bare(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin3", "../git-repo3"])
        .success();

    // create bookmark and push to origin
    work_dir.run_jj(["bookmark", "create", "main"]).success();
    work_dir.run_jj(["bookmark", "track", "main"]).success();
    work_dir.run_jj(["describe", "-m", "a"]).success();
    work_dir.run_jj(["git", "push", "-b", "main"]).success();

    // adjust main and push to origin2, again for origin3
    work_dir
        .run_jj(["describe", "-m", "b", "-r", "main", "--ignore-immutable"])
        .success();
    work_dir
        .run_jj(["git", "push", "-b", "main", "--remote=origin2"])
        .success();
    work_dir
        .run_jj(["describe", "-m", "c", "-r", "main", "--ignore-immutable"])
        .success();
    work_dir
        .run_jj(["git", "push", "-b", "main", "--remote=origin3"])
        .success();

    // stop and retrack origin; creates conflict
    // origin2 and origin3 are not shown
    work_dir
        .run_jj(["bookmark", "untrack", "main", "--remote=origin"])
        .success();
    let output = work_dir.run_jj(["bookmark", "track", "main", "--remote=origin"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Started tracking 1 remote bookmarks.
    main (conflicted):
      + qpvuntsm/0 cfb13288 (divergent) (empty) c
      + qpvuntsm/2 6b9445d7 (divergent) (empty) a
      @origin (behind by 1 commits): qpvuntsm/2 6b9445d7 (divergent) (empty) a
    [EOF]
    ");

    // origin2 differs but is not in conflict
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    main (conflicted):
      + qpvuntsm/0 cfb13288 (divergent) (empty) c
      + qpvuntsm/2 6b9445d7 (divergent) (empty) a
      @origin (behind by 1 commits): qpvuntsm/2 6b9445d7 (divergent) (empty) a
      @origin2 (ahead by 1 commits, behind by 2 commits): qpvuntsm/1 bdc03fa9 (hidden) (empty) b
      @origin3 (behind by 1 commits): qpvuntsm/0 cfb13288 (divergent) (empty) c
    [EOF]
    ");

    // retracking origin2 adds to the conflict
    work_dir
        .run_jj(["bookmark", "untrack", "main", "--remote=origin2"])
        .success();
    let output = work_dir.run_jj(["bookmark", "track", "main", "--remote=origin2"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Started tracking 1 remote bookmarks.
    main (conflicted):
      + qpvuntsm/0 cfb13288 (divergent) (empty) c
      + qpvuntsm/2 6b9445d7 (divergent) (empty) a
      + qpvuntsm/1 bdc03fa9 (divergent) (empty) b
      @origin2 (behind by 2 commits): qpvuntsm/1 bdc03fa9 (divergent) (empty) b
    [EOF]
    ");
}

#[test]
fn test_bookmark_track_untrack_patterns() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up remote
    let git_repo_path = test_env.env_root().join("git-repo");
    let git_repo = git::init(git_repo_path);
    work_dir
        .run_jj(["git", "remote", "add", "origin", "../git-repo"])
        .success();

    // Create remote commit
    create_commit_with_refs(
        &git_repo,
        "commit",
        b"content",
        &["refs/heads/feature1", "refs/heads/feature2"],
    );

    // Fetch new commit without auto tracking
    test_env.add_config("remotes.origin.auto-track-bookmarks = '~*'");
    let output = work_dir.run_jj(["git", "fetch"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@origin [new] untracked
    bookmark: feature2@origin [new] untracked
    [EOF]
    ");

    // Track/untrack new bookmark that doesn't exist at remote
    work_dir.run_jj(["bookmark", "create", "main"]).success();
    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "track", "main"]), @"
    ------- stderr -------
    Started tracking 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "untrack", "main"]), @"
    ------- stderr -------
    Stopped tracking 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(
        work_dir.run_jj(["bookmark", "untrack", "main", "--remote=o*"]), @"
    ------- stderr -------
    Warning: Remote bookmark not tracked yet: main@origin
    Nothing changed.
    [EOF]
    ");

    // Track/untrack unknown bookmark
    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "track", "maine*"]), @"
    ------- stderr -------
    Nothing changed.
    [EOF]
    ");
    insta::assert_snapshot!(
        work_dir.run_jj(["bookmark", "untrack", "maine", "--remote=o* | unknown"]), @"
    ------- stderr -------
    Warning: No matching bookmarks for names: maine
    Warning: No matching remotes for names: unknown
    Nothing changed.
    [EOF]
    ");

    // Track already tracked bookmark
    work_dir.run_jj(["bookmark", "track", "feature1"]).success();
    let output = work_dir.run_jj(["bookmark", "track", "feature1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Remote bookmark already tracked: feature1@origin
    Nothing changed.
    [EOF]
    ");

    // Untrack non-tracking bookmark
    let output = work_dir.run_jj(["bookmark", "untrack", "feature2"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Remote bookmark not tracked yet: feature2@origin
    Nothing changed.
    [EOF]
    ");

    // Track/untrack Git-tracking bookmark
    work_dir.run_jj(["git", "export"]).success();
    let output = work_dir.run_jj(["bookmark", "track", "main", "--remote=git"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Remote bookmark already tracked: main@git
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["bookmark", "untrack", "main", "--remote=git"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Git-tracking bookmark cannot be untracked: main@git
    Nothing changed.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: yrnqsqlx 41e7a49d commit
      @git: yrnqsqlx 41e7a49d commit
      @origin: yrnqsqlx 41e7a49d commit
    feature2@origin: yrnqsqlx 41e7a49d commit
    main: qpvuntsm e8849ae1 (empty) (no description set)
      @git: qpvuntsm e8849ae1 (empty) (no description set)
    [EOF]
    ");

    // Git-tracking remote should not be warned by default
    let output = work_dir.run_jj(["bookmark", "track", "main"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Started tracking 1 remote bookmarks.
    [EOF]
    ");

    // Untrack by pattern
    let output = work_dir.run_jj(["bookmark", "untrack", "~main", "--remote=*"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Git-tracking bookmark cannot be untracked: feature1@git
    Warning: Remote bookmark not tracked yet: feature2@origin
    Stopped tracking 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: yrnqsqlx 41e7a49d commit
      @git: yrnqsqlx 41e7a49d commit
    feature1@origin: yrnqsqlx 41e7a49d commit
    feature2@origin: yrnqsqlx 41e7a49d commit
    main: qpvuntsm e8849ae1 (empty) (no description set)
      @git: qpvuntsm e8849ae1 (empty) (no description set)
      @origin (not created yet)
    [EOF]
    ");

    // Track by pattern
    let output = work_dir.run_jj(["bookmark", "track", "'feature?' | main", "--remote=~git"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Remote bookmark already tracked: main@origin
    Started tracking 2 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: yrnqsqlx 41e7a49d commit
      @git: yrnqsqlx 41e7a49d commit
      @origin: yrnqsqlx 41e7a49d commit
    feature2: yrnqsqlx 41e7a49d commit
      @origin: yrnqsqlx 41e7a49d commit
    main: qpvuntsm e8849ae1 (empty) (no description set)
      @git: qpvuntsm e8849ae1 (empty) (no description set)
      @origin (not created yet)
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_bookmark_track_untrack_bad_args() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let output = work_dir.run_jj(["bookmark", "track", "--remote=foo", "bar@baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: --remote cannot be used with <bookmark>@<remote> symbols
    [EOF]
    [exit status: 2]
    ");

    let output = work_dir.run_jj(["bookmark", "track", "foo", "bar@baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Cannot specify both <bookmark> patterns and <bookmark>@<remote> symbols
    [EOF]
    [exit status: 2]
    ");

    let output = work_dir.run_jj(["bookmark", "track", "~foo@bar"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to parse name pattern or remote symbol: Invalid string expression
    Caused by:  --> 1:2
      |
    1 | ~foo@bar
      |  ^-----^
      |
      = Invalid string expression
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["bookmark", "untrack", "--remote=foo", "bar@baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: --remote cannot be used with <bookmark>@<remote> symbols
    [EOF]
    [exit status: 2]
    ");

    let output = work_dir.run_jj(["bookmark", "untrack", "foo", "bar@baz"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Cannot specify both <bookmark> patterns and <bookmark>@<remote> symbols
    [EOF]
    [exit status: 2]
    ");
}

#[test]
fn test_bookmark_track_absent() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Set up remotes
    let git_repo1 = {
        let git_repo_path = test_env.env_root().join("remote1");
        let git_repo = git::init(git_repo_path);
        work_dir
            .run_jj(["git", "remote", "add", "remote1", "../remote1"])
            .success();
        git_repo
    };
    let _git_repo2 = {
        let git_repo_path = test_env.env_root().join("remote2");
        let git_repo = git::init(git_repo_path);
        work_dir
            .run_jj(["git", "remote", "add", "remote2", "../remote2"])
            .success();
        git_repo
    };

    // Create feature1@remote1
    create_commit_with_refs(&git_repo1, "commit", b"", &["refs/heads/feature1"]);

    let output = work_dir.run_jj(["git", "fetch", "--all-remotes"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    bookmark: feature1@remote1 [new] untracked
    [EOF]
    ");

    // Track feature1: remote2 isn't tracked because there's no local bookmark
    insta::assert_snapshot!(
        work_dir.run_jj(["bookmark", "track", "feature1", "--remote=*"]), @"
    ------- stderr -------
    Started tracking 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: quutswnw 3fb14832 commit
      @remote1: quutswnw 3fb14832 commit
    [EOF]
    ");

    // Track feature1 again: remote2 is now tracked
    insta::assert_snapshot!(
        work_dir.run_jj(["bookmark", "track", "feature1", "--remote=*"]), @"
    ------- stderr -------
    Warning: Remote bookmark already tracked: feature1@remote1
    Started tracking 1 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: quutswnw 3fb14832 commit
      @remote1: quutswnw 3fb14832 commit
      @remote2 (not created yet)
    [EOF]
    ");

    // Track newly-created bookmark: both remotes are tracked
    work_dir
        .run_jj(["bookmark", "create", "new-feature"])
        .success();
    insta::assert_snapshot!(
        work_dir.run_jj(["bookmark", "track", "new-feature", "--remote=*"]), @"
    ------- stderr -------
    Started tracking 2 remote bookmarks.
    [EOF]
    ");
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    feature1: quutswnw 3fb14832 commit
      @remote1: quutswnw 3fb14832 commit
      @remote2 (not created yet)
    new-feature: qpvuntsm e8849ae1 (empty) (no description set)
      @remote1 (not created yet)
      @remote2 (not created yet)
    [EOF]
    ");

    // Track newly-created bookmarks by <name>@<remote> syntax
    work_dir
        .run_jj(["bookmark", "create", "new2", "'new 3'"])
        .success();
    let output = work_dir.run_jj([
        "bookmark",
        "track",
        "new2@remote1",
        "'new 3'@remote2",
        "unknown@remote1",
        "'new 3'@unknown",
    ]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Warning: No matching remote bookmarks for names: unknown@remote1, "new 3"@unknown
    Started tracking 2 remote bookmarks.
    [EOF]
    "#);
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @r#"
    feature1: quutswnw 3fb14832 commit
      @remote1: quutswnw 3fb14832 commit
      @remote2 (not created yet)
    "new 3": qpvuntsm e8849ae1 (empty) (no description set)
      @remote2 (not created yet)
    new-feature: qpvuntsm e8849ae1 (empty) (no description set)
      @remote1 (not created yet)
      @remote2 (not created yet)
    new2: qpvuntsm e8849ae1 (empty) (no description set)
      @remote1 (not created yet)
    [EOF]
    "#);

    Ok(())
}

#[test]
fn test_bookmark_list() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");

    // Initialize remote refs
    test_env.run_jj_in(".", ["git", "init", "remote"]).success();
    let remote_dir = test_env.work_dir("remote");
    for bookmark in [
        "remote-sync",
        "remote-unsync",
        "remote-untrack",
        "remote-delete",
    ] {
        remote_dir
            .run_jj(["new", "root()", "-m", bookmark])
            .success();
        remote_dir
            .run_jj(["bookmark", "create", bookmark])
            .success();
    }
    remote_dir.run_jj(["new"]).success();
    remote_dir.run_jj(["git", "export"]).success();

    // Initialize local refs
    let mut remote_git_path = remote_dir.root().to_owned();
    remote_git_path.extend([".jj", "repo", "store", "git"]);
    test_env
        .run_jj_in(
            ".",
            ["git", "clone", remote_git_path.to_str().unwrap(), "local"],
        )
        .success();
    let local_dir = test_env.work_dir("local");
    local_dir
        .run_jj(["new", "root()", "-m", "local-only"])
        .success();
    local_dir
        .run_jj([
            "--config=remotes.origin.auto-track-bookmarks='~*'",
            "bookmark",
            "create",
            "local-only",
            "absent-tracked",
        ])
        .success();

    // Mutate refs in local repository
    local_dir
        .run_jj(["bookmark", "delete", "remote-delete"])
        .success();
    local_dir
        .run_jj(["bookmark", "delete", "remote-untrack"])
        .success();
    local_dir
        .run_jj(["bookmark", "track", "absent-tracked"])
        .success();
    local_dir
        .run_jj(["bookmark", "untrack", "remote-untrack"])
        .success();
    local_dir
        .run_jj(["bookmark", "set", "--allow-backwards", "remote-unsync"])
        .success();

    // Synchronized tracking remotes and non-tracking remotes aren't listed by
    // default
    let output = local_dir.run_jj(["bookmark", "list"]);
    insta::assert_snapshot!(output, @"
    absent-tracked: wqnwkozp 0353dd35 (empty) local-only
      @origin (not created yet)
    local-only: wqnwkozp 0353dd35 (empty) local-only
    remote-delete (deleted)
      @origin: vruxwmqv b32031cf (empty) remote-delete
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: wqnwkozp 0353dd35 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--all-remotes"]);
    insta::assert_snapshot!(output, @"
    absent-tracked: wqnwkozp 0353dd35 (empty) local-only
      @origin (not created yet)
    local-only: wqnwkozp 0353dd35 (empty) local-only
    remote-delete (deleted)
      @origin: vruxwmqv b32031cf (empty) remote-delete
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
      @origin: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: wqnwkozp 0353dd35 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    remote-untrack@origin: royxmykx 149bc756 (empty) remote-untrack
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--all-remotes", "--color=always"]);
    insta::assert_snapshot!(output, @"
    [38;5;5mabsent-tracked[39m: [1m[38;5;13mw[38;5;8mqnwkozp[39m [38;5;12m03[38;5;8m53dd35[39m [38;5;10m(empty)[39m local-only[0m
      [38;5;5m@origin[39m (not created yet)
    [38;5;5mlocal-only[39m: [1m[38;5;13mw[38;5;8mqnwkozp[39m [38;5;12m03[38;5;8m53dd35[39m [38;5;10m(empty)[39m local-only[0m
    [38;5;5mremote-delete[39m (deleted)
      [38;5;5m@origin[39m: [1m[38;5;5mv[0m[38;5;8mruxwmqv[39m [1m[38;5;4mb[0m[38;5;8m32031cf[39m [38;5;2m(empty)[39m remote-delete
    [38;5;5mremote-sync[39m: [1m[38;5;5mr[0m[38;5;8mlvkpnrz[39m [1m[38;5;4m7[0m[38;5;8ma07dbee[39m [38;5;2m(empty)[39m remote-sync
      [38;5;5m@origin[39m: [1m[38;5;5mr[0m[38;5;8mlvkpnrz[39m [1m[38;5;4m7[0m[38;5;8ma07dbee[39m [38;5;2m(empty)[39m remote-sync
    [38;5;5mremote-unsync[39m: [1m[38;5;13mw[38;5;8mqnwkozp[39m [38;5;12m03[38;5;8m53dd35[39m [38;5;10m(empty)[39m local-only[0m
      [38;5;5m@origin[39m (ahead by 1 commits, behind by 1 commits): [1m[38;5;5mzs[0m[38;5;8muskuln[39m [1m[38;5;4m5[0m[38;5;8m53203ba[39m [38;5;2m(empty)[39m remote-unsync
    [38;5;5mremote-untrack@origin[39m: [1m[38;5;5mro[0m[38;5;8myxmykx[39m [1m[38;5;4m1[0m[38;5;8m49bc756[39m [38;5;2m(empty)[39m remote-untrack
    [EOF]
    ------- stderr -------
    [1m[38;5;6mHint: [0m[39mBookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.[39m
    [EOF]
    ");

    let template = r#"
    concat(
      "[" ++ name ++ if(remote, "@" ++ remote) ++ "]\n",
      separate(" ", "present:", present) ++ "\n",
      separate(" ", "conflict:", conflict) ++ "\n",
      separate(" ", "normal_target:", normal_target.description().first_line()) ++ "\n",
      separate(" ", "removed_targets:", removed_targets.map(|c| c.description().first_line())) ++ "\n",
      separate(" ", "added_targets:", added_targets.map(|c| c.description().first_line())) ++ "\n",
      separate(" ", "tracked:", tracked) ++ "\n",
      separate(" ", "tracking_present:", tracking_present) ++ "\n",
      separate(" ", "tracking_ahead_count:", tracking_ahead_count.lower()) ++ "\n",
      separate(" ", "tracking_behind_count:", tracking_behind_count.lower()) ++ "\n",
    )
    "#;
    let output = local_dir.run_jj(["bookmark", "list", "--all-remotes", "-T", template]);
    insta::assert_snapshot!(output, @"
    [absent-tracked]
    present: true
    conflict: false
    normal_target: local-only
    removed_targets:
    added_targets: local-only
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [absent-tracked@origin]
    present: false
    conflict: false
    normal_target: <Error: No Commit available>
    removed_targets:
    added_targets:
    tracked: true
    tracking_present: true
    tracking_ahead_count: 0
    tracking_behind_count: 2
    [local-only]
    present: true
    conflict: false
    normal_target: local-only
    removed_targets:
    added_targets: local-only
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [remote-delete]
    present: false
    conflict: false
    normal_target: <Error: No Commit available>
    removed_targets:
    added_targets:
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [remote-delete@origin]
    present: true
    conflict: false
    normal_target: remote-delete
    removed_targets:
    added_targets: remote-delete
    tracked: true
    tracking_present: false
    tracking_ahead_count: 2
    tracking_behind_count: 0
    [remote-sync]
    present: true
    conflict: false
    normal_target: remote-sync
    removed_targets:
    added_targets: remote-sync
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [remote-sync@origin]
    present: true
    conflict: false
    normal_target: remote-sync
    removed_targets:
    added_targets: remote-sync
    tracked: true
    tracking_present: true
    tracking_ahead_count: 0
    tracking_behind_count: 0
    [remote-unsync]
    present: true
    conflict: false
    normal_target: local-only
    removed_targets:
    added_targets: local-only
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [remote-unsync@origin]
    present: true
    conflict: false
    normal_target: remote-unsync
    removed_targets:
    added_targets: remote-unsync
    tracked: true
    tracking_present: true
    tracking_ahead_count: 1
    tracking_behind_count: 1
    [remote-untrack@origin]
    present: true
    conflict: false
    normal_target: remote-untrack
    removed_targets:
    added_targets: remote-untrack
    tracked: false
    tracking_present: false
    tracking_ahead_count: <Error: Not a tracked remote ref>
    tracking_behind_count: <Error: Not a tracked remote ref>
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", r#"-Tjson(self) ++ "\n""#]);
    insta::assert_snapshot!(output, @r#"
    {"name":"absent-tracked","target":["0353dd35c56156971ce5f023a1db7a6196160a8a"]}
    {"name":"absent-tracked","remote":"origin","target":[null],"tracking_target":["0353dd35c56156971ce5f023a1db7a6196160a8a"]}
    {"name":"local-only","target":["0353dd35c56156971ce5f023a1db7a6196160a8a"]}
    {"name":"remote-delete","target":[null]}
    {"name":"remote-delete","remote":"origin","target":["b32031cf329fbb90d042635c295b4e3fa2ca2651"],"tracking_target":[null]}
    {"name":"remote-sync","target":["7a07dbeef135886b7ba7adb27d05190c39cd92ab"]}
    {"name":"remote-unsync","target":["0353dd35c56156971ce5f023a1db7a6196160a8a"]}
    {"name":"remote-unsync","remote":"origin","target":["553203baa52803406124962dbc0bcdc0227b20b2"],"tracking_target":["0353dd35c56156971ce5f023a1db7a6196160a8a"]}
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    "#);
    Ok(())
}

#[test]
fn test_bookmark_list_filtered() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
    test_env.add_config(r#"revset-aliases."immutable_heads()" = "none()""#);

    // Initialize remote refs
    test_env.run_jj_in(".", ["git", "init", "remote"]).success();
    let remote_dir = test_env.work_dir("remote");
    for bookmark in ["remote-keep", "remote-delete", "remote-rewrite"] {
        remote_dir
            .run_jj(["new", "root()", "-m", bookmark])
            .success();
        remote_dir
            .run_jj(["bookmark", "create", bookmark])
            .success();
    }
    remote_dir.run_jj(["new"]).success();
    remote_dir.run_jj(["git", "export"]).success();

    // Initialize local refs
    let mut remote_git_path = remote_dir.root().to_owned();
    remote_git_path.extend([".jj", "repo", "store", "git"]);
    test_env
        .run_jj_in(
            ".",
            ["git", "clone", remote_git_path.to_str().unwrap(), "local"],
        )
        .success();
    let local_dir = test_env.work_dir("local");
    local_dir
        .run_jj(["new", "root()", "-m", "local-keep"])
        .success();
    local_dir
        .run_jj([
            "--config=remotes.origin.auto-track-bookmarks='~*'",
            "bookmark",
            "create",
            "local-keep",
        ])
        .success();

    // Mutate refs in local repository
    local_dir
        .run_jj(["bookmark", "delete", "remote-delete"])
        .success();
    local_dir
        .run_jj(["describe", "-mrewritten", "remote-rewrite"])
        .success();

    let template = r#"separate(" ", commit_id.short(), bookmarks, if(hidden, "(hidden)"))"#;
    insta::assert_snapshot!(
        local_dir.run_jj(["log", "-r::(bookmarks() | remote_bookmarks())", "-T", template]), @"
    @  4b2bc95cbda6 local-keep
    │ ○  e6970e0e1f55 remote-rewrite*
    ├─╯
    │ ○  331d500d2fda remote-rewrite@origin (hidden)
    ├─╯
    │ ○  0e6b796871e6 remote-delete@origin
    ├─╯
    │ ○  c2f2ee40f03a remote-keep
    ├─╯
    ◆  000000000000
    [EOF]
    ");

    // All bookmarks are listed by default.
    let output = local_dir.run_jj(["bookmark", "list"]);
    insta::assert_snapshot!(output, @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let query =
        |args: &[&str]| local_dir.run_jj_with(|cmd| cmd.args(["bookmark", "list"]).args(args));

    // "all()" doesn't include deleted bookmarks since they have no local targets.
    // So "all()" is identical to "bookmarks()".
    insta::assert_snapshot!(query(&["-rall()"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");

    // Exclude remote-only bookmarks. "remote-rewrite@origin" is included since
    // local "remote-rewrite" target matches.
    insta::assert_snapshot!(query(&["-rbookmarks()"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");

    // Select bookmarks by name.
    insta::assert_snapshot!(query(&["remote-rewrite"]), @"
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");
    insta::assert_snapshot!(query(&["-rbookmarks(remote-rewrite)"]), @"
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");

    // Select bookmarks by name, combined with --all-remotes
    local_dir.run_jj(["git", "export"]).success();
    insta::assert_snapshot!(query(&["--all-remotes", "remote-rewrite"]), @"
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @git: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");
    insta::assert_snapshot!(query(&["--all-remotes", "-rbookmarks(remote-rewrite)"]), @"
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @git: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");

    // Select bookmarks with --remote
    insta::assert_snapshot!(query(&["--remote", "origin"]), @"
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
      @origin: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");
    insta::assert_snapshot!(query(&["--remote", "'gi?'"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
      @git: kpqxywon 4b2bc95c (empty) local-keep
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
      @git: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @git: royxmykx e6970e0e (empty) rewritten
    [EOF]
    ");
    insta::assert_snapshot!(query(&["--remote", "origin", "--remote", "git"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
      @git: kpqxywon 4b2bc95c (empty) local-keep
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
      @git: rlvkpnrz c2f2ee40 (empty) remote-keep
      @origin: rlvkpnrz c2f2ee40 (empty) remote-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @git: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    // Can select deleted bookmark by name pattern, but not by revset.
    insta::assert_snapshot!(query(&["remote-delete"]), @"
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");
    insta::assert_snapshot!(query(&["-rbookmarks(remote-delete)"]), @"");
    insta::assert_snapshot!(query(&["-rremote-delete"]), @"
    ------- stderr -------
    Error: Revision `remote-delete` doesn't exist
    Hint: Did you mean `remote-delete@origin`, `remote-keep`, `remote-rewrite`, `remote-rewrite@origin`?
    [EOF]
    [exit status: 1]
    ");

    // Name patterns are OR-ed.
    insta::assert_snapshot!(query(&["*-keep", "remote-* & *-delete"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    remote-keep: rlvkpnrz c2f2ee40 (empty) remote-keep
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    // Unmatched exact name pattern should be warned. "remote-delete" exists in
    // remote. "remote-rewrite" exists, but isn't included in the match.
    insta::assert_snapshot!(
        query(&["local-keep", "push-*", "unknown | remote-delete ~ remote-rewrite"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-delete (deleted)
      @origin: zsuskuln 0e6b7968 (empty) remote-delete
    [EOF]
    ------- stderr -------
    Warning: No matching bookmarks for names: unknown
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    // Name pattern and revset are OR-ed.
    insta::assert_snapshot!(query(&["local-keep", "-rbookmarks(remote-rewrite)"]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @origin (ahead by 1 commits, behind by 1 commits): royxmykx/1 331d500d (hidden) (empty) remote-rewrite
    [EOF]
    ");

    // … but still filtered by --remote
    insta::assert_snapshot!(query(&[
        "local-keep",
        "-rbookmarks(remote-rewrite)",
        "--remote",
        "git",
    ]), @"
    local-keep: kpqxywon 4b2bc95c (empty) local-keep
      @git: kpqxywon 4b2bc95c (empty) local-keep
    remote-rewrite: royxmykx e6970e0e (empty) rewritten
      @git: royxmykx e6970e0e (empty) rewritten
    [EOF]
    ");

    // Syntax error in name pattern
    insta::assert_snapshot!(query(&["foo &"]), @"
    ------- stderr -------
    Error: Failed to parse name pattern: Syntax error
    Caused by:  --> 1:6
      |
    1 | foo &
      |      ^---
      |
      = expected `::`, `..`, `~`, or <primary>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for revsets syntax and how to quote symbols.
    [EOF]
    [exit status: 1]
    ");
    Ok(())
}

#[test]
fn test_bookmark_list_quoted_name() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["bookmark", "create", "-r@", "'with space'"])
        .success();

    // quoted by default
    let output = work_dir.run_jj(["bookmark", "list"]);
    insta::assert_snapshot!(output, @r#"
    "with space": qpvuntsm e8849ae1 (empty) (no description set)
    [EOF]
    "#);

    // string method should apply to the original (unquoted) name
    let template = r#"
    separate(' ',
      self,
      name.contains('"'),
      name.len(),
    ) ++ "\n"
    "#;
    let output = work_dir.run_jj(["bookmark", "list", "-T", template]);
    insta::assert_snapshot!(output, @r#"
    "with space" false 10
    [EOF]
    "#);
}

#[test]
fn test_bookmark_list_much_remote_divergence() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");

    // Initialize remote refs
    test_env.run_jj_in(".", ["git", "init", "remote"]).success();
    let remote_dir = test_env.work_dir("remote");
    remote_dir
        .run_jj(["new", "root()", "-m", "remote-unsync"])
        .success();
    for _ in 0..15 {
        remote_dir.run_jj(["new", "-m", "remote-unsync"]).success();
    }
    remote_dir
        .run_jj(["bookmark", "create", "-r@", "remote-unsync"])
        .success();
    remote_dir.run_jj(["new"]).success();
    remote_dir.run_jj(["git", "export"]).success();

    // Initialize local refs
    let mut remote_git_path = remote_dir.root().to_owned();
    remote_git_path.extend([".jj", "repo", "store", "git"]);
    test_env
        .run_jj_in(
            ".",
            ["git", "clone", remote_git_path.to_str().unwrap(), "local"],
        )
        .success();
    let local_dir = test_env.work_dir("local");
    local_dir
        .run_jj(["new", "root()", "-m", "local-only"])
        .success();
    for _ in 0..15 {
        local_dir.run_jj(["new", "-m", "local-only"]).success();
    }
    local_dir
        .run_jj([
            "--config=remotes.origin.auto-track-bookmarks='~*'",
            "bookmark",
            "create",
            "local-only",
        ])
        .success();

    // Mutate refs in local repository
    local_dir
        .run_jj(["bookmark", "set", "--allow-backwards", "remote-unsync"])
        .success();

    let output = local_dir.run_jj(["bookmark", "list"]);
    insta::assert_snapshot!(output, @"
    local-only: zkyosouw a30800ad (empty) local-only
    remote-unsync: zkyosouw a30800ad (empty) local-only
      @origin (ahead by at least 10 commits, behind by at least 10 commits): uyznsvlq a52367f8 (empty) remote-unsync
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_bookmark_list_tracked() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
    test_env.add_config("remotes.upstream.auto-track-bookmarks = '*'");

    // Initialize remote refs
    test_env.run_jj_in(".", ["git", "init", "remote"]).success();
    let remote_dir = test_env.work_dir("remote");
    for bookmark in [
        "remote-sync",
        "remote-unsync",
        "remote-untrack",
        "remote-delete",
    ] {
        remote_dir
            .run_jj(["new", "root()", "-m", bookmark])
            .success();
        remote_dir
            .run_jj(["bookmark", "create", bookmark])
            .success();
    }
    remote_dir.run_jj(["new"]).success();
    remote_dir.run_jj(["git", "export"]).success();

    // Initialize local refs
    let mut remote_git_path = remote_dir.root().to_owned();
    remote_git_path.extend([".jj", "repo", "store", "git"]);
    test_env
        .run_jj_in(
            ".",
            [
                "git",
                "clone",
                "--colocate",
                remote_git_path.to_str().unwrap(),
                "local",
            ],
        )
        .success();

    test_env
        .run_jj_in(".", ["git", "init", "upstream"])
        .success();

    // Initialize a second remote
    let upstream_dir = test_env.work_dir("upstream");
    upstream_dir
        .run_jj(["new", "root()", "-m", "upstream-sync"])
        .success();
    upstream_dir
        .run_jj(["bookmark", "create", "upstream-sync"])
        .success();
    upstream_dir.run_jj(["new"]).success();
    upstream_dir.run_jj(["git", "export"]).success();

    let mut upstream_git_path = upstream_dir.root().to_owned();
    upstream_git_path.extend([".jj", "repo", "store", "git"]);

    let local_dir = test_env.work_dir("local");

    local_dir
        .run_jj([
            "git",
            "remote",
            "add",
            "upstream",
            upstream_git_path.to_str().unwrap(),
        ])
        .success();
    local_dir
        .run_jj(["git", "fetch", "--all-remotes"])
        .success();

    local_dir
        .run_jj(["new", "root()", "-m", "local-only"])
        .success();
    local_dir
        .run_jj([
            "--config=remotes.origin.auto-track-bookmarks='~*'",
            "--config=remotes.upstream.auto-track-bookmarks='~*'",
            "bookmark",
            "create",
            "local-only",
        ])
        .success();

    // Mutate refs in local repository
    local_dir
        .run_jj(["bookmark", "delete", "remote-delete"])
        .success();
    local_dir
        .run_jj(["bookmark", "delete", "remote-untrack"])
        .success();
    local_dir
        .run_jj(["bookmark", "untrack", "remote-untrack", "--remote=origin"])
        .success();
    local_dir
        .run_jj(["bookmark", "track", "remote-unsync", "--remote=upstream"])
        .success();
    local_dir
        .run_jj([
            "git",
            "push",
            "--remote=upstream",
            "--bookmark=remote-unsync",
        ])
        .success();
    local_dir
        .run_jj(["bookmark", "set", "--allow-backwards", "remote-unsync"])
        .success();

    let output = local_dir.run_jj(["bookmark", "list", "--all-remotes"]);
    insta::assert_snapshot!(output, @"
    local-only: nmzmmopx 2a685e16 (empty) local-only
      @git: nmzmmopx 2a685e16 (empty) local-only
    remote-delete (deleted)
      @origin: vruxwmqv b32031cf (empty) remote-delete
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
      @git: rlvkpnrz 7a07dbee (empty) remote-sync
      @origin: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @git: nmzmmopx 2a685e16 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
      @upstream (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    remote-untrack@origin: royxmykx 149bc756 (empty) remote-untrack
    upstream-sync: lylxulpl 169ba7d9 (empty) upstream-sync
      @git: lylxulpl 169ba7d9 (empty) upstream-sync
      @upstream: lylxulpl 169ba7d9 (empty) upstream-sync
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--tracked"]);
    insta::assert_snapshot!(output, @"
    remote-delete (deleted)
      @origin: vruxwmqv b32031cf (empty) remote-delete
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
      @origin: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
      @upstream (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    upstream-sync: lylxulpl 169ba7d9 (empty) upstream-sync
      @upstream: lylxulpl 169ba7d9 (empty) upstream-sync
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--tracked", "--remote", "origin"]);
    insta::assert_snapshot!(output, @"
    remote-delete (deleted)
      @origin: vruxwmqv b32031cf (empty) remote-delete
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
      @origin: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    [EOF]
    ------- stderr -------
    Hint: Bookmarks marked as deleted can be *deleted permanently* on the remote by running `jj git push --deleted`. Use `jj bookmark forget` if you don't want that.
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--tracked", "--remote=git"]);
    insta::assert_snapshot!(output, @"
    local-only: nmzmmopx 2a685e16 (empty) local-only
      @git: nmzmmopx 2a685e16 (empty) local-only
    remote-sync: rlvkpnrz 7a07dbee (empty) remote-sync
      @git: rlvkpnrz 7a07dbee (empty) remote-sync
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @git: nmzmmopx 2a685e16 (empty) local-only
    upstream-sync: lylxulpl 169ba7d9 (empty) upstream-sync
      @git: lylxulpl 169ba7d9 (empty) upstream-sync
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--tracked", "remote-unsync"]);
    insta::assert_snapshot!(output, @"
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
      @upstream (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    [EOF]
    ");

    let output = local_dir.run_jj(["bookmark", "list", "--tracked", "remote-untrack"]);
    insta::assert_snapshot!(output, @"");

    local_dir
        .run_jj(["bookmark", "untrack", "remote-unsync", "--remote=upstream"])
        .success();

    let output = local_dir.run_jj(["bookmark", "list", "--tracked", "remote-unsync"]);
    insta::assert_snapshot!(output, @"
    remote-unsync: nmzmmopx 2a685e16 (empty) local-only
      @origin (ahead by 1 commits, behind by 1 commits): zsuskuln 553203ba (empty) remote-unsync
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_bookmark_list_conflicted() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Track existing bookmark. Local bookmark should result in conflict.
    work_dir.run_jj(["new", "root()", "-m", "a"]).success();
    work_dir.run_jj(["new", "root()", "-m", "b"]).success();
    work_dir.run_jj(["bookmark", "create", "bar"]).success();
    work_dir
        .run_jj(["bookmark", "create", "foo", "-rsubject(a)"])
        .success();
    work_dir
        .run_jj(["bookmark", "create", "foo", "-rsubject(b)", "--at-op=@-"])
        .success();
    work_dir.run_jj(["status"]).success();
    insta::assert_snapshot!(get_bookmark_output(&work_dir), @"
    bar: kkmpptxz a82129fb (empty) b
    foo (conflicted):
      + rlvkpnrz 4e1b2d80 (empty) a
      + kkmpptxz a82129fb (empty) b
    [EOF]
    ");
    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "list", "--conflicted"]), @"
    foo (conflicted):
      + rlvkpnrz 4e1b2d80 (empty) a
      + kkmpptxz a82129fb (empty) b
    [EOF]
    ------- stderr -------
    Hint: Some bookmarks have conflicts. Use `jj bookmark set <name> -r <rev>` to resolve.
    [EOF]
    ");
}

#[test]
fn test_bookmark_list_sort_unknown_key_error() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "list", "--sort", "date"]), @"
    ------- stderr -------
    error: invalid value 'date' for '--sort <SORT_KEY>'
      [possible values: name, name-, author-name, author-name-, author-email, author-email-, author-date, author-date-, committer-name, committer-name-, committer-email, committer-email-, committer-date, committer-date-]

    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");
}

#[test]
fn test_bookmark_list_sort_multiple_keys() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    for (bookmark, email) in [("c", "bob@g.c"), ("b", "alice@g.c"), ("a", "bob@g.c")] {
        work_dir
            .run_jj([
                &format!("--config=user.email={email}"),
                "new",
                "root()",
                "-m",
                "fix",
            ])
            .success();
        work_dir.run_jj(["bookmark", "create", bookmark]).success();
    }

    let template =
        r#"name ++ ": " ++ if(normal_target, normal_target.committer().email()) ++ "\n""#;
    insta::assert_snapshot!(work_dir.run_jj(["bookmark", "list", "-T", template, "--sort", "committer-email,committer-date-"]), @"
    b: alice@g.c
    a: bob@g.c
    c: bob@g.c
    [EOF]
    ");
}

#[test]
fn test_bookmark_list_sort_using_config() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    for (bookmark, email) in [("c", "bob@g.c"), ("b", "alice@g.c"), ("a", "bob@g.c")] {
        work_dir
            .run_jj([
                &format!("--config=user.email={email}"),
                "new",
                "root()",
                "-m",
                "fix",
            ])
            .success();
        work_dir.run_jj(["bookmark", "create", bookmark]).success();
    }

    let template = r#"name ++ ": " ++ if(normal_target, normal_target.author().email()) ++ "\n""#;
    insta::assert_snapshot!(work_dir.run_jj([
        "--config=ui.bookmark-list-sort-keys=['author-email', 'author-date-']",
        "bookmark",
        "list",
        "-T",
        template
    ]), @"
    b: alice@g.c
    a: bob@g.c
    c: bob@g.c
    [EOF]
    ");
}

#[test]
fn test_bookmark_list_sort_overriding_config() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    for (bookmark, email) in [("c", "bob@g.c"), ("b", "alice@g.c"), ("a", "bob@g.c")] {
        work_dir
            .run_jj([
                &format!("--config=user.email={email}"),
                "new",
                "root()",
                "-m",
                "fix",
            ])
            .success();
        work_dir.run_jj(["bookmark", "create", bookmark]).success();
    }

    let template = r#"name ++ ": " ++ if(normal_target, normal_target.author().email()) ++ "\n""#;
    insta::assert_snapshot!(work_dir.run_jj([
        "--config=ui.bookmark-list-sort-keys=['author-email', 'author-date-']",
        "bookmark",
        "list",
        "--sort=name-", // overriding config.
        "-T",
        template
    ]), @"
    c: bob@g.c
    b: alice@g.c
    a: bob@g.c
    [EOF]
    ");
}

#[test]
fn test_create_and_set_auto_track_bookmarks() {
    let test_env = TestEnvironment::default();
    let root_dir = test_env.work_dir("");
    root_dir
        .run_jj(["git", "init", "--colocate", "origin"])
        .success();

    // auto-tracking an unknown remote (upstream) will have no effect
    test_env.add_config(
        "
        [remotes.origin]
        auto-track-bookmarks = 'mine/*'
        [remotes.fork]
        auto-track-bookmarks = 'mine/* | not-mine/*'
        [remotes.upstream]
        auto-track-bookmarks = '*'
        ",
    );

    root_dir.run_jj(["git", "init", "repo"]).success();
    let repo_dir = test_env.work_dir("repo");
    repo_dir
        .run_jj(["git", "remote", "add", "origin", "../origin/.git"])
        .success();
    repo_dir
        .run_jj(["git", "remote", "add", "fork", "dummy"])
        .success();

    // jj bookmark create obeys remotes.<name>.auto-track-bookmarks
    repo_dir
        .run_jj(["bookmark", "create", "mine/create", "not-mine/create"])
        .success();
    let output = repo_dir.run_jj([
        "bookmark",
        "list",
        "--all",
        "mine/create",
        "not-mine/create",
    ]);
    insta::assert_snapshot!(output, @"
    mine/create: rlvkpnrz 7eb1c95e (empty) (no description set)
      @fork (not created yet)
      @origin (not created yet)
    not-mine/create: rlvkpnrz 7eb1c95e (empty) (no description set)
      @fork (not created yet)
    [EOF]
    ");
    repo_dir.run_jj(["commit", "--message", "create"]).success();

    // jj bookmark set obeys remotes.<name>.auto-track-bookmarks
    repo_dir
        .run_jj(["bookmark", "set", "mine/set", "not-mine/set"])
        .success();
    let output = repo_dir.run_jj(["bookmark", "list", "--all", "mine/set", "not-mine/set"]);
    insta::assert_snapshot!(output, @"
    mine/set: yqosqzyt 5fbe2b20 (empty) (no description set)
      @fork (not created yet)
      @origin (not created yet)
    not-mine/set: yqosqzyt 5fbe2b20 (empty) (no description set)
      @fork (not created yet)
    [EOF]
    ");
    repo_dir.run_jj(["commit", "--message", "set"]).success();

    // jj bookmark create/set warns when auto-tracking existing bookmark
    repo_dir.run_jj(["git", "push"]).success();
    repo_dir
        .run_jj(["bookmark", "forget", "mine/create", "mine/set"])
        .success();
    let output = repo_dir.run_jj(["bookmark", "create", "mine/create"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Warning: Auto-tracking bookmark that exists on the remote: mine/create@origin
    Created 1 bookmarks pointing to znkkpsqq 2e899fb8 mine/create* | (empty) (no description set)
    [EOF]
    ");
    let output = repo_dir.run_jj(["bookmark", "set", "mine/set"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Warning: Auto-tracking bookmark that exists on the remote: mine/set@origin
    Created 1 bookmarks pointing to znkkpsqq 2e899fb8 mine/create* mine/set* | (empty) (no description set)
    [EOF]
    ");
}

#[test]
fn test_create_and_set_auto_track_created_bookmarks() {
    let test_env = TestEnvironment::default();
    let root_dir = test_env.work_dir("");
    root_dir
        .run_jj(["git", "init", "--colocate", "origin"])
        .success();

    test_env.add_config(
        "
        [remotes.origin]
        auto-track-created-bookmarks = 'mine/*'
        auto-track-bookmarks = 'also-mine/*'
        ",
    );

    root_dir.run_jj(["git", "init", "repo"]).success();
    let repo_dir = test_env.work_dir("repo");
    repo_dir
        .run_jj(["git", "remote", "add", "origin", "../origin/.git"])
        .success();

    repo_dir
        .run_jj([
            "bookmark",
            "create",
            "mine/create",
            "also-mine/create",
            "not-mine/create",
        ])
        .success();
    let output = repo_dir.run_jj(["bookmark", "list", "--all", "*/create"]);
    insta::assert_snapshot!(output, @"
    also-mine/create: rlvkpnrz 7eb1c95e (empty) (no description set)
      @origin (not created yet)
    mine/create: rlvkpnrz 7eb1c95e (empty) (no description set)
      @origin (not created yet)
    not-mine/create: rlvkpnrz 7eb1c95e (empty) (no description set)
    [EOF]
    ");
    repo_dir.run_jj(["commit", "--message", "create"]).success();

    repo_dir
        .run_jj([
            "bookmark",
            "set",
            "mine/set",
            "also-mine/set",
            "not-mine/set",
        ])
        .success();
    let output = repo_dir.run_jj(["bookmark", "list", "--all", "*/set"]);
    insta::assert_snapshot!(output, @"
    also-mine/set: royxmykx 5fd666db (empty) (no description set)
      @origin (not created yet)
    mine/set: royxmykx 5fd666db (empty) (no description set)
      @origin (not created yet)
    not-mine/set: royxmykx 5fd666db (empty) (no description set)
    [EOF]
    ");
    repo_dir.run_jj(["commit", "--message", "set"]).success();
}

#[test]
fn test_bad_auto_track_bookmarks() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // silence "Target revision is empty" warning
    work_dir.write_file("file", "");

    let output = work_dir.run_jj([
        "bookmark",
        "create",
        "a",
        "--config=remotes.origin.auto-track-bookmarks=''",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Invalid `remotes.origin.auto-track-bookmarks`: Syntax error
    Caused by:  --> 1:1
      |
    1 | 
      | ^---
      |
      = expected <expression>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for revsets syntax and how to quote symbols.
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj([
        "bookmark",
        "create",
        "a",
        "--config=remotes.origin.auto-track-bookmarks='foo &'",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Invalid `remotes.origin.auto-track-bookmarks`: Syntax error
    Caused by:  --> 1:6
      |
    1 | foo &
      |      ^---
      |
      = expected `::`, `..`, `~`, or <primary>
    Hint: See https://docs.jj-vcs.dev/latest/revsets/ or use `jj help -k revsets` for revsets syntax and how to quote symbols.
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj([
        "bookmark",
        "create",
        "a",
        "--config=remotes.origin.auto-track-bookmarks=[{}]",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Invalid type or value for remotes.origin
    Caused by: invalid type: sequence, expected a string
    in `auto-track-bookmarks`

    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_bookmark_advance_default() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let get_log = || {
        let template = r#"
            separate(" ",
                change_id.shortest(8),
                description.first_line(),
                if(empty, "(empty)"),
                bookmarks,
            )
        "#;
        work_dir.run_jj(["log", "-T", template])
    };

    work_dir.run_jj(["describe", "-m", "a"]).success();
    work_dir.run_jj(["bookmark", "create", "A"]).success();
    work_dir.run_jj(["new", "-m", "b"]).success();
    std::fs::write(work_dir.root().join("file"), "content")?;
    work_dir.run_jj(["bookmark", "create", "B"]).success();
    work_dir.run_jj(["new", "-m", "c"]).success();
    std::fs::write(work_dir.root().join("file"), "new_content")?;
    work_dir.run_jj(["new", "-m", "d"]).success();
    work_dir.run_jj(["new", "-m", "e"]).success();
    std::fs::write(work_dir.root().join("file"), "newer_content")?;

    insta::assert_snapshot!(get_log(), @r"
    @  vruxwmqv e
    ○  yqosqzyt d (empty)
    ○  royxmykx c
    ○  zsuskuln b B
    ○  qpvuntsm a (empty) A
    ◆  zzzzzzzz (empty)
    [EOF]
    ");

    let setup_opid = work_dir.current_operation_id();

    // To default target.
    let output = work_dir.run_jj(["bookmark", "advance"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Advanced 1 bookmarks to vruxwmqv 7753a73e B | e
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // To target specified by --to.
    let output = work_dir.run_jj(["bookmark", "advance", "--to", "royxmykx"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Advanced 1 bookmarks to royxmykx 26554c67 B | c
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // A -> default target.
    let output = work_dir.run_jj(["bookmark", "advance", "A"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Advanced 1 bookmarks to vruxwmqv 7753a73e A | e
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // A -> target specified by --to.
    let output = work_dir.run_jj(["bookmark", "advance", "A", "--to", "zsuskuln"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Advanced 1 bookmarks to zsuskuln 4112c46e A B | b
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // A -> empty default target.
    work_dir.run_jj(["new"]).success();
    let output = work_dir.run_jj(["bookmark", "advance", "A"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Target revision is empty.
    Advanced 1 bookmarks to xznxytkn ced0e1e4 A | (empty) (no description set)
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Multiple bookmarks by name.
    let output = work_dir.run_jj(["bookmark", "advance", "A|B"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Advanced 2 bookmarks to vruxwmqv 7753a73e A B | e
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Multiple bookmarks from config.
    let output = work_dir.run_jj([
        "--config=revsets.bookmark-advance-from=A|B",
        "bookmark",
        "advance",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Advanced 2 bookmarks to vruxwmqv 7753a73e A B | e
    Hint: Specify bookmark by name to update just one of the bookmarks.
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // At a bookmark.
    work_dir.run_jj(["edit", "zsuskuln"]).success();
    let output = work_dir.run_jj(["bookmark", "advance"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    No bookmarks to update.
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Not fast-forward
    let output = work_dir.run_jj(["bookmark", "advance", "B", "--to", "A"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Refusing to advance bookmark backwards or sideways: B
    [EOF]
    [exit status: 1]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // No bookmark to push.
    work_dir.run_jj(["new", "root()"]).success();
    std::fs::write(work_dir.root().join("file"), "content")?;
    work_dir.run_jj(["new"]).success();
    let output = work_dir.run_jj(["bookmark", "advance"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    No bookmarks to update.
    [EOF]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Not valid target.
    work_dir.run_jj(["new", "root()"]).success();
    std::fs::write(work_dir.root().join("file"), "content")?;
    work_dir.run_jj(["new"]).success();
    let output = work_dir.run_jj(["bookmark", "advance", "-t", "none()"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Revset `none()` didn't resolve to any revisions
    [EOF]
    [exit status: 1]
    ");
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Bad default target from config.
    let output = work_dir.run_jj([
        "--config=revsets.bookmark-advance-to=none()",
        "bookmark",
        "advance",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Revset `none()` didn't resolve to any revisions
    Hint: `revsets.bookmark-advance-to` controls the default target. You can also specify a specific target with `--to`.
    [EOF]
    [exit status: 1]
    ");
    Ok(())
}

#[must_use]
fn get_log_output(work_dir: &TestWorkDir) -> CommandOutput {
    let template = r#"bookmarks ++ " " ++ commit_id.short()"#;
    work_dir.run_jj(["log", "-T", template])
}

#[must_use]
fn get_bookmark_output(work_dir: &TestWorkDir) -> CommandOutput {
    // --quiet to suppress deleted bookmarks hint
    work_dir.run_jj(["bookmark", "list", "--all-remotes", "--quiet"])
}
