cmd: --branch option for git fetch.

Thanks to @samueltardieu for noticing a subtle bug in the refspecs, providing
the fix, as well as the two `conflicting_branches` tests.
This commit is contained in:
Ilya Grigoriev 2023-01-12 14:53:26 -08:00
parent cd8a18daf8
commit 30d03a66e6
5 changed files with 519 additions and 2 deletions

View file

@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `jj rebase` now accepts multiple `-s` and `-b` arguments. Revsets with
multiple commits are allowed with `--allow-large-revsets`.
* `jj git fetch` now supports a `--branch` argument to fetch some of the
branches only.
### Fixed bugs
* Modify/delete conflicts now include context lines

View file

@ -330,6 +330,8 @@ pub fn export_refs(
pub enum GitFetchError {
#[error("No git remote named '{0}'")]
NoSuchRemote(String),
#[error("Invalid glob provided. Globs may not contain the characters `:` or `^`.")]
InvalidGlob,
// TODO: I'm sure there are other errors possible, such as transport-level errors.
#[error("Unexpected git error when fetching: {0}")]
InternalGitError(#[from] git2::Error),
@ -340,6 +342,7 @@ pub fn fetch(
mut_repo: &mut MutableRepo,
git_repo: &git2::Repository,
remote_name: &str,
globs: &[String],
callbacks: RemoteCallbacks<'_>,
git_settings: &GitSettings,
) -> Result<Option<String>, GitFetchError> {
@ -361,9 +364,17 @@ pub fn fetch(
fetch_options.proxy_options(proxy_options);
let callbacks = callbacks.into_git();
fetch_options.remote_callbacks(callbacks);
let refspec: &[&str] = &[];
if globs.iter().any(|g| g.contains(|c| ":^".contains(c))) {
return Err(GitFetchError::InvalidGlob);
}
// At this point, we are only updating Git's remote tracking branches, not the
// local branches.
let refspecs = globs
.iter()
.map(|glob| format!("+refs/heads/{glob}:refs/remotes/{remote_name}/{glob}"))
.collect_vec();
tracing::debug!("remote.download");
remote.download(refspec, Some(&mut fetch_options))?;
remote.download(&refspecs, Some(&mut fetch_options))?;
tracing::debug!("remote.prune");
remote.prune(None)?;
tracing::debug!("remote.update_tips");

View file

@ -957,6 +957,7 @@ fn test_fetch_empty_repo() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -980,6 +981,7 @@ fn test_fetch_initial_commit() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1021,6 +1023,7 @@ fn test_fetch_success() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1041,6 +1044,7 @@ fn test_fetch_success() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1082,6 +1086,7 @@ fn test_fetch_prune_deleted_ref() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1100,6 +1105,7 @@ fn test_fetch_prune_deleted_ref() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1120,6 +1126,7 @@ fn test_fetch_no_default_branch() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1142,6 +1149,7 @@ fn test_fetch_no_default_branch() {
tx.mut_repo(),
&test_data.git_repo,
"origin",
&[],
git::RemoteCallbacks::default(),
&git_settings,
)
@ -1161,6 +1169,7 @@ fn test_fetch_no_such_remote() {
tx.mut_repo(),
&test_data.git_repo,
"invalid-remote",
&[],
git::RemoteCallbacks::default(),
&git_settings,
);

View file

@ -85,6 +85,12 @@ pub struct GitRemoteListArgs {}
/// Fetch from a Git remote
#[derive(clap::Args, Clone, Debug)]
pub struct GitFetchArgs {
/// Fetch only some of the branches (caution: known bugs)
///
/// Any `*` in the argument is expanded as a glob. So, one `--branch` can
/// match several branches.
#[arg(long)]
branch: Vec<String>,
/// The remote to fetch from (only named remotes are supported, can be
/// repeated)
#[arg(long = "remote", value_name = "remote")]
@ -263,6 +269,7 @@ fn cmd_git_fetch(
tx.mut_repo(),
&git_repo,
&remote,
&args.branch,
cb,
&command.settings().git_settings(),
)
@ -390,6 +397,7 @@ fn do_git_clone(
fetch_tx.mut_repo(),
&git_repo,
remote_name,
&[],
cb,
&command.settings().git_settings(),
)
@ -399,6 +407,9 @@ fn do_git_clone(
panic!("shouldn't happen as we just created the git remote")
}
GitFetchError::InternalGitError(err) => user_error(format!("Fetch failed: {err}")),
GitFetchError::InvalidGlob => {
unreachable!("we didn't provide any globs")
}
})?;
fetch_tx.finish(ui)?;
Ok((workspace_command, maybe_default_branch))

View file

@ -50,6 +50,32 @@ fn get_branch_output(test_env: &TestEnvironment, repo_path: &Path) -> String {
test_env.jj_cmd_success(repo_path, &["branch", "list"])
}
fn create_commit(test_env: &TestEnvironment, repo_path: &Path, name: &str, parents: &[&str]) {
let descr = format!("descr_for_{name}");
if parents.is_empty() {
test_env.jj_cmd_success(repo_path, &["new", "root", "-m", &descr]);
} else {
let mut args = vec!["new", "-m", &descr];
args.extend(parents);
test_env.jj_cmd_success(repo_path, &args);
}
std::fs::write(repo_path.join(name), format!("{name}\n")).unwrap();
test_env.jj_cmd_success(repo_path, &["branch", "create", name]);
}
fn get_log_output(test_env: &TestEnvironment, workspace_root: &Path) -> String {
test_env.jj_cmd_success(
workspace_root,
&[
"log",
"-T",
r#"commit_id.short() " " description.first_line() " " branches"#,
"-r",
"all()",
],
)
}
#[test]
fn test_git_fetch_default_remote() {
let test_env = TestEnvironment::default();
@ -182,3 +208,460 @@ fn test_git_fetch_prune_before_updating_tips() {
origin/subname: 9f01a0e04879 message
"###);
}
#[test]
fn test_git_fetch_conflicting_branches() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
let repo_path = test_env.env_root().join("repo");
add_git_remote(&test_env, &repo_path, "rem1");
// Create a rem1 branch locally
test_env.jj_cmd_success(&repo_path, &["new", "root"]);
test_env.jj_cmd_success(&repo_path, &["branch", "create", "rem1"]);
insta::assert_snapshot!(get_branch_output(&test_env, &repo_path), @r###"
rem1: fcdbbd731496 (no description set)
"###);
test_env.jj_cmd_success(
&repo_path,
&["git", "fetch", "--remote", "rem1", "--branch", "*"],
);
// This should result in a CONFLICTED branch
insta::assert_snapshot!(get_branch_output(&test_env, &repo_path), @r###"
rem1 (conflicted):
+ fcdbbd731496 (no description set)
+ 9f01a0e04879 message
@rem1 (behind by 1 commits): 9f01a0e04879 message
"###);
}
#[test]
fn test_git_fetch_conflicting_branches_colocated() {
let test_env = TestEnvironment::default();
let repo_path = test_env.env_root().join("repo");
let _git_repo = git2::Repository::init(&repo_path).unwrap();
// create_colocated_repo_and_branches_from_trunk1(&test_env, &repo_path);
test_env.jj_cmd_success(&repo_path, &["init", "--git-repo", "."]);
add_git_remote(&test_env, &repo_path, "rem1");
insta::assert_snapshot!(get_branch_output(&test_env, &repo_path), @"");
// Create a rem1 branch locally
test_env.jj_cmd_success(&repo_path, &["new", "root"]);
test_env.jj_cmd_success(&repo_path, &["branch", "create", "rem1"]);
insta::assert_snapshot!(get_branch_output(&test_env, &repo_path), @r###"
rem1: f652c32197cf (no description set)
"###);
test_env.jj_cmd_success(
&repo_path,
&["git", "fetch", "--remote", "rem1", "--branch", "rem1"],
);
// This should result in a CONFLICTED branch
// See https://github.com/martinvonz/jj/pull/1146#discussion_r1112372340 for the bug this tests for.
insta::assert_snapshot!(get_branch_output(&test_env, &repo_path), @r###"
rem1 (conflicted):
+ f652c32197cf (no description set)
+ 9f01a0e04879 message
@rem1 (behind by 1 commits): 9f01a0e04879 message
"###);
}
// Helper functions to test obtaining multiple branches at once and changed
// branches
fn create_colocated_repo_and_branches_from_trunk1(
test_env: &TestEnvironment,
repo_path: &Path,
) -> String {
// Create a colocated repo in `source` to populate it more easily
test_env.jj_cmd_success(repo_path, &["init", "--git-repo", "."]);
create_commit(test_env, repo_path, "trunk1", &[]);
create_commit(test_env, repo_path, "a1", &["trunk1"]);
create_commit(test_env, repo_path, "a2", &["trunk1"]);
create_commit(test_env, repo_path, "b", &["trunk1"]);
format!(
" ===== Source git repo contents =====\n{}",
get_log_output(test_env, repo_path)
)
}
fn create_trunk2_and_rebase_branches(test_env: &TestEnvironment, repo_path: &Path) -> String {
create_commit(test_env, repo_path, "trunk2", &["trunk1"]);
for br in ["a1", "a2", "b"] {
test_env.jj_cmd_success(repo_path, &["rebase", "-b", br, "-d", "trunk2"]);
}
format!(
" ===== Source git repo contents =====\n{}",
get_log_output(test_env, repo_path)
)
}
#[test]
fn test_git_fetch_all() {
let test_env = TestEnvironment::default();
let source_git_repo_path = test_env.env_root().join("source");
let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap();
// Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated
let stdout =
test_env.jj_cmd_success(test_env.env_root(), &["git", "clone", "source", "target"]);
insta::assert_snapshot!(stdout, @r###"
Fetching into new repo in "$TEST_ENV/target"
Nothing changed.
"###);
let target_jj_repo_path = test_env.env_root().join("target");
let source_log =
create_colocated_repo_and_branches_from_trunk1(&test_env, &source_git_repo_path);
insta::assert_snapshot!(source_log, @r###"
===== Source git repo contents =====
@ c7d4bdcbc215 descr_for_b b
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1 master trunk1
o 000000000000
"###);
// Nothing in our repo before the fetch
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
@ 230dd059e1b0
o 000000000000
"###);
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @"");
insta::assert_snapshot!(test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch"]), @"");
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
a1: 359a9a02457d descr_for_a1
a2: decaa3966c83 descr_for_a2
b: c7d4bdcbc215 descr_for_b
master: ff36dc55760e descr_for_trunk1
trunk1: ff36dc55760e descr_for_trunk1
"###);
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o c7d4bdcbc215 descr_for_b b
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1 master trunk1
@ 230dd059e1b0
o 000000000000
"###);
// ==== Change both repos ====
// First, change the target repo:
let source_log = create_trunk2_and_rebase_branches(&test_env, &source_git_repo_path);
insta::assert_snapshot!(source_log, @r###"
===== Source git repo contents =====
o babc49226c14 descr_for_b b
o 91e46b4b2653 descr_for_a2 a2
o 0424f6dfc1ff descr_for_a1 a1
@ 8f1f14fbbf42 descr_for_trunk2 trunk2
o ff36dc55760e descr_for_trunk1 master trunk1
o 000000000000
"###);
// Change a branch in the source repo as well, so that it becomes conflicted.
test_env.jj_cmd_success(
&target_jj_repo_path,
&["describe", "b", "-m=new_descr_for_b_to_create_conflict"],
);
// Our repo before and after fetch
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o 061eddbb43ab new_descr_for_b_to_create_conflict b*
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1 master trunk1
@ 230dd059e1b0
o 000000000000
"###);
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
a1: 359a9a02457d descr_for_a1
a2: decaa3966c83 descr_for_a2
b: 061eddbb43ab new_descr_for_b_to_create_conflict
@origin (ahead by 1 commits, behind by 1 commits): c7d4bdcbc215 descr_for_b
master: ff36dc55760e descr_for_trunk1
trunk1: ff36dc55760e descr_for_trunk1
"###);
insta::assert_snapshot!(test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch"]), @"");
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
a1: 0424f6dfc1ff descr_for_a1
a2: 91e46b4b2653 descr_for_a2
b (conflicted):
- c7d4bdcbc215 descr_for_b
+ 061eddbb43ab new_descr_for_b_to_create_conflict
+ babc49226c14 descr_for_b
@origin (behind by 1 commits): babc49226c14 descr_for_b
master: ff36dc55760e descr_for_trunk1
trunk1: ff36dc55760e descr_for_trunk1
trunk2: 8f1f14fbbf42 descr_for_trunk2
"###);
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o babc49226c14 descr_for_b b?? b@origin
o 91e46b4b2653 descr_for_a2 a2
o 0424f6dfc1ff descr_for_a1 a1
o 8f1f14fbbf42 descr_for_trunk2 trunk2
o 061eddbb43ab new_descr_for_b_to_create_conflict b??
o ff36dc55760e descr_for_trunk1 master trunk1
@ 230dd059e1b0
o 000000000000
"###);
}
#[test]
fn test_git_fetch_some_of_many_branches() {
let test_env = TestEnvironment::default();
let source_git_repo_path = test_env.env_root().join("source");
let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap();
// Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated
let stdout =
test_env.jj_cmd_success(test_env.env_root(), &["git", "clone", "source", "target"]);
insta::assert_snapshot!(stdout, @r###"
Fetching into new repo in "$TEST_ENV/target"
Nothing changed.
"###);
let target_jj_repo_path = test_env.env_root().join("target");
let source_log =
create_colocated_repo_and_branches_from_trunk1(&test_env, &source_git_repo_path);
insta::assert_snapshot!(source_log, @r###"
===== Source git repo contents =====
@ c7d4bdcbc215 descr_for_b b
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1 master trunk1
o 000000000000
"###);
// Test an error message
let stderr =
test_env.jj_cmd_failure(&target_jj_repo_path, &["git", "fetch", "--branch", "^:a*"]);
insta::assert_snapshot!(stderr, @r###"
Error: Invalid glob provided. Globs may not contain the characters `:` or `^`.
"###);
// Nothing in our repo before the fetch
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
@ 230dd059e1b0
o 000000000000
"###);
// Fetch one branch...
let stdout = test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch", "--branch", "b"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o c7d4bdcbc215 descr_for_b b
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
// ...check what the intermediate state looks like...
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
b: c7d4bdcbc215 descr_for_b
"###);
// ...then fetch two others with a glob.
let stdout = test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch", "--branch", "a*"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o c7d4bdcbc215 descr_for_b b
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
// Fetching the same branch again
let stdout = test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch", "--branch", "a1"]);
insta::assert_snapshot!(stdout, @r###"
Nothing changed.
"###);
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o c7d4bdcbc215 descr_for_b b
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
// ==== Change both repos ====
// First, change the target repo:
let source_log = create_trunk2_and_rebase_branches(&test_env, &source_git_repo_path);
insta::assert_snapshot!(source_log, @r###"
===== Source git repo contents =====
o 13ac032802f1 descr_for_b b
o 010977d69c5b descr_for_a2 a2
o 6f4e1c4dfe29 descr_for_a1 a1
@ 09430ba04a82 descr_for_trunk2 trunk2
o ff36dc55760e descr_for_trunk1 master trunk1
o 000000000000
"###);
// Change a branch in the source repo as well, so that it becomes conflicted.
test_env.jj_cmd_success(
&target_jj_repo_path,
&["describe", "b", "-m=new_descr_for_b_to_create_conflict"],
);
// Our repo before and after fetch of two branches
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o 2be688d8c664 new_descr_for_b_to_create_conflict b*
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
let stdout = test_env.jj_cmd_success(
&target_jj_repo_path,
&["git", "fetch", "--branch", "b", "--branch", "a1"],
);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o 13ac032802f1 descr_for_b b?? b@origin
o 6f4e1c4dfe29 descr_for_a1 a1
o 09430ba04a82 descr_for_trunk2
o 2be688d8c664 new_descr_for_b_to_create_conflict b??
o decaa3966c83 descr_for_a2 a2
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
// We left a2 where it was before, let's see how `jj branch list` sees this.
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
a1: 6f4e1c4dfe29 descr_for_a1
a2: decaa3966c83 descr_for_a2
b (conflicted):
- c7d4bdcbc215 descr_for_b
+ 2be688d8c664 new_descr_for_b_to_create_conflict
+ 13ac032802f1 descr_for_b
@origin (behind by 1 commits): 13ac032802f1 descr_for_b
"###);
// Now, let's fetch a2 and double-check that fetching a1 and b again doesn't do
// anything.
let stdout = test_env.jj_cmd_success(
&target_jj_repo_path,
&["git", "fetch", "--branch", "b", "--branch", "a*"],
);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o 010977d69c5b descr_for_a2 a2
o 13ac032802f1 descr_for_b b?? b@origin
o 6f4e1c4dfe29 descr_for_a1 a1
o 09430ba04a82 descr_for_trunk2
o 2be688d8c664 new_descr_for_b_to_create_conflict b??
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
insta::assert_snapshot!(get_branch_output(&test_env, &target_jj_repo_path), @r###"
a1: 6f4e1c4dfe29 descr_for_a1
a2: 010977d69c5b descr_for_a2
b (conflicted):
- c7d4bdcbc215 descr_for_b
+ 2be688d8c664 new_descr_for_b_to_create_conflict
+ 13ac032802f1 descr_for_b
@origin (behind by 1 commits): 13ac032802f1 descr_for_b
"###);
}
// TODO: Fix the bug this test demonstrates. (https://github.com/martinvonz/jj/issues/1300)
// The issue likely stems from the fact that `jj undo` does not undo the fetch
// inside the git repo backing the `target` repo. It is unclear whether it
// should.
#[test]
fn test_git_fetch_undo() {
let test_env = TestEnvironment::default();
let source_git_repo_path = test_env.env_root().join("source");
let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap();
// Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated
let stdout =
test_env.jj_cmd_success(test_env.env_root(), &["git", "clone", "source", "target"]);
insta::assert_snapshot!(stdout, @r###"
Fetching into new repo in "$TEST_ENV/target"
Nothing changed.
"###);
let target_jj_repo_path = test_env.env_root().join("target");
let source_log =
create_colocated_repo_and_branches_from_trunk1(&test_env, &source_git_repo_path);
insta::assert_snapshot!(source_log, @r###"
===== Source git repo contents =====
@ c7d4bdcbc215 descr_for_b b
o decaa3966c83 descr_for_a2 a2
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1 master trunk1
o 000000000000
"###);
// Fetch 2 branches
let stdout = test_env.jj_cmd_success(
&target_jj_repo_path,
&["git", "fetch", "--branch", "b", "--branch", "a1"],
);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o c7d4bdcbc215 descr_for_b b
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
insta::assert_snapshot!(test_env.jj_cmd_success(&target_jj_repo_path, &["undo"]), @"");
// The undo works as expected
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
@ 230dd059e1b0
o 000000000000
"###);
// Now try to fetch just one branch
let stdout = test_env.jj_cmd_success(&target_jj_repo_path, &["git", "fetch", "--branch", "b"]);
insta::assert_snapshot!(stdout, @"");
// BUG: Both branches got fetched.
insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###"
o c7d4bdcbc215 descr_for_b b
o 359a9a02457d descr_for_a1 a1
o ff36dc55760e descr_for_trunk1
@ 230dd059e1b0
o 000000000000
"###);
}