diff --git a/josh-proxy/src/bin/josh-proxy.rs b/josh-proxy/src/bin/josh-proxy.rs index d707db0e4..2193731f0 100644 --- a/josh-proxy/src/bin/josh-proxy.rs +++ b/josh-proxy/src/bin/josh-proxy.rs @@ -633,6 +633,7 @@ async fn call_service( base_ns: josh::to_ns(&parsed_url.upstream_repo), git_ns: temp_ns.name().to_string(), git_dir: repo_path.to_string(), + stacked_changes: ARGS.is_present("stacked-changes"), }; let mut cmd = Command::new("git"); @@ -850,6 +851,11 @@ fn make_app() -> clap::App<'static> { .long("no-background") .takes_value(false), ) + .arg( + clap::Arg::new("stacked-changes") + .long("stacked-changes") + .takes_value(false), + ) .arg( clap::Arg::new("graphql-root") .long("graphql-root") diff --git a/josh-proxy/src/lib.rs b/josh-proxy/src/lib.rs index 040d2848a..2f893db01 100644 --- a/josh-proxy/src/lib.rs +++ b/josh-proxy/src/lib.rs @@ -4,7 +4,7 @@ pub mod juniper_hyper; #[macro_use] extern crate lazy_static; -fn baseref_and_options(refname: &str) -> josh::JoshResult<(String, String, Vec)> { +fn baseref_and_options(refname: &str) -> josh::JoshResult<(String, String, Vec, bool)> { let mut split = refname.splitn(2, '%'); let push_to = split.next().ok_or(josh::josh_error("no next"))?.to_owned(); @@ -15,14 +15,16 @@ fn baseref_and_options(refname: &str) -> josh::JoshResult<(String, String, Vec josh::JoshResult { - let mut resp = String::new(); - let p = std::path::PathBuf::from(&repo_update.git_dir) .join("refs/namespaces") .join(&repo_update.git_ns) @@ -59,7 +60,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult let old = git2::Oid::from_str(old)?; - let (baseref, push_to, options) = baseref_and_options(refname)?; + let (baseref, push_to, options, for_review) = baseref_and_options(refname)?; let josh_merge = push_options.contains_key("merge"); tracing::debug!("push options: {:?}", push_options); @@ -120,26 +121,9 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult None }; - let amends = std::collections::HashMap::new(); - //let amends = { - // let gerrit_changes = format!( - // "refs/josh/upstream/{}/refs/changes/*", - // repo_update.base_ns, - // ); - // let mut amends = std::collections::HashMap::new(); - // for reference in - // transaction.repo().references_glob(&gerrit_changes)? - // { - // if let Ok(commit) = transaction.repo().find_commit( - // reference?.target().unwrap_or(git2::Oid::zero()), - // ) { - // if let Some(id) = josh::get_change_id(&commit) { - // amends.insert(id, commit.id()); - // } - // } - // } - // amends - //}; + let stacked_changes = repo_update.stacked_changes && for_review; + + let mut change_ids = if stacked_changes { Some(vec![]) } else { None }; let filterobj = josh::filter::parse(&repo_update.filter_spec)?; let new_oid = git2::Oid::from_str(new)?; @@ -156,7 +140,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult new_oid, josh_merge, reparent_orphans, - &amends, + &mut change_ids, )? { josh::UnapplyResult::Done(rewritten) => { tracing::debug!("rewritten"); @@ -203,45 +187,68 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult backward_new_oid }; - let push_with_options = if !options.is_empty() { + let ref_with_options = if !options.is_empty() { format!("{}{}{}", push_to, "%", options.join(",")) } else { push_to }; - let reapply = josh::filter::apply_to_commit( - filterobj, - &transaction.repo().find_commit(oid_to_push)?, - &transaction, - )?; + let author = if let Some(p) = push_options.get("author") { + p.to_string() + } else { + "".to_string() + }; - let (text, status) = push_head_url( - transaction.repo(), - oid_to_push, - &push_with_options, - &repo_update.remote_url, - &repo_update.auth, - &repo_update.git_ns, - )?; + let to_push = if let Some(change_ids) = change_ids { + let mut v = vec![( + format!( + "refs/heads/@heads/{}/{}", + baseref.replacen("refs/heads/", "", 1), + author, + ), + oid_to_push, + )]; + v.append(&mut change_ids_to_refs(baseref, author, change_ids)?); + v + } else { + vec![(ref_with_options, oid_to_push)] + }; - let warnings = josh::filter::compute_warnings( - &transaction, - filterobj, - transaction.repo().find_commit(oid_to_push)?.tree()?, - ); + let mut resp = vec![]; + + for (reference, oid) in to_push { + let (text, status) = push_head_url( + transaction.repo(), + oid, + &reference, + &repo_update.remote_url, + &repo_update.auth, + &repo_update.git_ns, + stacked_changes, + )?; + if status != 0 { + return Err(josh::josh_error(&text)); + } - let mut warning_str = "".to_owned(); - if !warnings.is_empty() { - let warnings = warnings.iter(); + resp.push(text.to_string()); - warning_str += "\nwarnings:"; - for warn in warnings { - warning_str += "\n"; - warning_str.push_str(warn); + let mut warnings = josh::filter::compute_warnings( + &transaction, + filterobj, + transaction.repo().find_commit(oid)?.tree()?, + ); + + if !warnings.is_empty() { + resp.push("warnings:".to_string()); + resp.append(&mut warnings); } } - resp = format!("{}{}{}", resp, text, warning_str); + let reapply = josh::filter::apply_to_commit( + filterobj, + &transaction.repo().find_commit(oid_to_push)?, + &transaction, + )?; if new_oid != reapply { transaction.repo().reference( @@ -255,14 +262,12 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult true, "reapply", )?; - resp = format!("{}\nREWRITE({} -> {})", resp, new_oid, reapply); - tracing::debug!("REWRITE({} -> {})", new_oid, reapply); + let text = format!("REWRITE({} -> {})", new_oid, reapply); + tracing::debug!("{}", text); + resp.push(text); } - if status == 0 { - return Ok(resp); - } - return Err(josh::josh_error(&resp)); + return Ok(resp.join("\n")); } Ok("".to_string()) @@ -275,6 +280,7 @@ fn push_head_url( url: &str, auth: &auth::Handle, namespace: &str, + force: bool, ) -> josh::JoshResult<(String, i32)> { let rn = format!("refs/{}", &namespace); @@ -284,7 +290,12 @@ fn push_head_url( cwd: repo.path().to_owned(), }; let (username, password) = auth.parse()?; - let cmd = format!("git push {} '{}'", &url, &spec); + let cmd = format!( + "git push {} {} '{}'", + if force { "-f" } else { "" }, + &url, + &spec + ); let mut fakehead = repo.reference(&rn, oid, true, "push_head_url")?; let (stdout, stderr, status) = shell.command_env( &cmd, @@ -468,3 +479,53 @@ impl Drop for TmpGitNamespace { }); } } + +fn change_ids_to_refs( + baseref: String, + change_author: String, + change_ids: Vec, +) -> josh::JoshResult> { + let mut seen = vec![]; + let mut change_ids = change_ids; + change_ids.retain(|change| change.author == change_author); + if !change_author.contains('@') { + return Err(josh::josh_error( + "Push option 'author' needs to be set to a valid email address", + )); + }; + + for change in change_ids.iter() { + if let Some(id) = &change.id { + if id.contains('@') { + return Err(josh::josh_error("Change-Id must not contain '@'")); + } + if seen.contains(&id) { + return Err(josh::josh_error(&format!( + "rejecting to push {:?} with duplicate Change-Id", + change.commit + ))); + } + seen.push(&id); + } else { + return Err(josh::josh_error(&format!( + "rejecting to push {:?} without Change-Id", + change.commit + ))); + } + } + + Ok(change_ids + .iter() + .map(|change| { + ( + format!( + "refs/heads/@changes/{}/{}/{}", + baseref.replacen("refs/heads/", "", 1), + change.author, + change.id.as_ref().unwrap_or(&"".to_string()), + ), + change.commit, + ) + }) + .collect()) +} diff --git a/src/bin/josh-filter.rs b/src/bin/josh-filter.rs index 3bdf19aa3..95903e525 100644 --- a/src/bin/josh-filter.rs +++ b/src/bin/josh-filter.rs @@ -325,7 +325,7 @@ fn run_filter(args: Vec) -> josh::JoshResult { new, false, None, - &std::collections::HashMap::new(), + &mut None, )? { josh::UnapplyResult::Done(rewritten) => { repo.reference(&src, rewritten, true, "unapply_filter")?; diff --git a/src/history.rs b/src/history.rs index 61686e69e..0f74511b7 100644 --- a/src/history.rs +++ b/src/history.rs @@ -268,7 +268,7 @@ fn find_new_branch_base( return Ok(git2::Oid::zero()); } -#[tracing::instrument(skip(transaction))] +#[tracing::instrument(skip(transaction, change_ids))] pub fn unapply_filter( transaction: &cache::Transaction, filterobj: filter::Filter, @@ -277,7 +277,7 @@ pub fn unapply_filter( new: git2::Oid, keep_orphans: bool, reparent_orphans: Option, - amends: &std::collections::HashMap, + change_ids: &mut Option>, ) -> JoshResult { let mut bm = std::collections::HashMap::new(); let mut ret = original_target; @@ -473,32 +473,8 @@ pub fn unapply_filter( &new_tree, )?; - if let Some(id) = super::get_change_id(&module_commit) { - if let Some(commit_id) = amends.get(&id) { - let mut merged_index = transaction.repo().merge_commits( - &transaction.repo().find_commit(*commit_id)?, - &transaction.repo().find_commit(ret)?, - Some(git2::MergeOptions::new().file_favor(git2::FileFavor::Theirs)), - )?; - - if merged_index.has_conflicts() { - return Ok(UnapplyResult::RejectAmend( - module_commit - .summary() - .unwrap_or("") - .to_string(), - )); - } - - let merged_tree = merged_index.write_tree_to(transaction.repo())?; - - ret = rewrite_commit( - transaction.repo(), - &module_commit, - &original_parents_refs, - &transaction.repo().find_tree(merged_tree)?, - )?; - } + if let Some(ref mut change_ids) = change_ids { + change_ids.push(super::get_change_id(&module_commit, ret)); } bm.insert(module_commit.id(), ret); diff --git a/src/lib.rs b/src/lib.rs index b981f18f6..d37ff6091 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,12 @@ pub enum UnapplyResult { BranchDoesNotExist, } +pub struct Change { + pub author: String, + pub id: Option, + pub commit: git2::Oid, +} + const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS .add(b'/') .add(b'*') @@ -153,14 +159,22 @@ lazy_static! { } } -pub fn get_change_id(commit: &git2::Commit) -> Option { +pub fn get_change_id(commit: &git2::Commit, sha: git2::Oid) -> Change { for line in commit.message().unwrap_or("").split('\n') { if line.starts_with("Change-Id: ") { let id = line.replace("Change-Id: ", ""); - return Some(id); + return Change { + author: commit.author().email().unwrap_or("").to_string(), + id: Some(id), + commit: sha, + }; } } - None + return Change { + author: commit.author().email().unwrap_or("").to_string(), + id: None, + commit: sha, + }; } #[tracing::instrument(skip(transaction))] diff --git a/tests/proxy/push_stacked.t b/tests/proxy/push_stacked.t new file mode 100644 index 000000000..90eed9653 --- /dev/null +++ b/tests/proxy/push_stacked.t @@ -0,0 +1,149 @@ + $ EXTRA_OPTS=--stacked-changes . ${TESTDIR}/setup_test_env.sh + $ cd ${TESTTMP} + + $ git clone -q http://localhost:8001/real_repo.git 1> /dev/null + warning: You appear to have cloned an empty repository. + $ cd real_repo + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + $ git push 1> /dev/null + To http://localhost:8001/real_repo.git + * [new branch] master -> master + + $ cd ${TESTTMP} + + $ git clone -q http://localhost:8002/real_repo.git sub1 + $ cd sub1 + + $ echo contents2 > file2 + $ git add file2 + $ git commit -m "Change-Id: 1234" 1> /dev/null + $ echo contents2 > file7 + $ git add file7 + $ git commit -m "Change-Id: foo7" 1> /dev/null + $ git log --decorate --graph --pretty="%s %d" + * Change-Id: foo7 (HEAD -> master) + * Change-Id: 1234 + * add file1 (origin/master, origin/HEAD) + $ git push -o author=foo@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @heads/master/foo@example.com + remote: + remote: + To http://localhost:8002/real_repo.git + * [new reference] master -> refs/for/master + $ git push -o author=josh@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @heads/master/josh@example.com + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/master/josh@example.com/1234 + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/master/josh@example.com/foo7 + remote: + remote: + To http://localhost:8002/real_repo.git + * [new reference] master -> refs/for/master + $ echo contents2 > file3 + $ git add file3 + $ git commit -m "add file3" 1> /dev/null + $ git push -o author=josh@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: rejecting to push b739883c1e5f388b0a5f715fc3beace7bf845bf2 without Change-Id + remote: + remote: + remote: error: hook declined to update refs/for/master + To http://localhost:8002/real_repo.git + ! [remote rejected] master -> refs/for/master (hook declined) + error: failed to push some refs to 'http://localhost:8002/real_repo.git' + [1] + $ git push -o author=foo@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: To http://localhost:8001/real_repo.git + remote: c35c443..b739883 JOSH_PUSH -> @heads/master/foo@example.com + remote: + remote: + To http://localhost:8002/real_repo.git + * [new reference] master -> refs/for/master + + $ curl -s http://localhost:8002/flush + Flushed credential cache + $ git fetch origin + From http://localhost:8002/real_repo + * [new branch] @changes/master/josh@example.com/1234 -> origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/foo7 -> origin/@changes/master/josh@example.com/foo7 + * [new branch] @heads/master/foo@example.com -> origin/@heads/master/foo@example.com + * [new branch] @heads/master/josh@example.com -> origin/@heads/master/josh@example.com + + $ git log --decorate --graph --pretty="%s %d" + * add file3 (HEAD -> master, origin/@heads/master/foo@example.com) + * Change-Id: foo7 (origin/@heads/master/josh@example.com, origin/@changes/master/josh@example.com/foo7) + * Change-Id: 1234 (origin/@changes/master/josh@example.com/1234) + * add file1 (origin/master, origin/HEAD) + + $ cd ${TESTTMP}/real_repo + $ git fetch origin + From http://localhost:8001/real_repo + * [new branch] @changes/master/josh@example.com/1234 -> origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/foo7 -> origin/@changes/master/josh@example.com/foo7 + * [new branch] @heads/master/foo@example.com -> origin/@heads/master/foo@example.com + * [new branch] @heads/master/josh@example.com -> origin/@heads/master/josh@example.com + $ git checkout -q heads/master/foo@example.com + error: pathspec 'heads/master/foo@example.com' did not match any file(s) known to git + [1] + $ git log --decorate --graph --pretty="%s %d" + * add file1 (HEAD -> master, origin/master) + + $ tree + . + `-- sub1 + `-- file1 + + 1 directory, 1 file + +Make sure all temporary namespace got removed + $ tree ${TESTTMP}/remote/scratch/real_repo.git/refs/ | grep request_ + [1] + + $ bash ${TESTDIR}/destroy_test_env.sh + "real_repo.git" = [':/sub1'] + refs + |-- heads + |-- josh + | |-- filtered + | | `-- real_repo.git + | | |-- %3A + | | | `-- HEAD + | | `-- %3A%2Fsub1 + | | `-- HEAD + | `-- upstream + | `-- real_repo.git + | |-- HEAD + | `-- refs + | `-- heads + | |-- @changes + | | `-- master + | | `-- josh@example.com + | | |-- 1234 + | | `-- foo7 + | |-- @heads + | | `-- master + | | |-- foo@example.com + | | `-- josh@example.com + | `-- master + |-- namespaces + `-- tags + + 17 directories, 8 files + +$ cat ${TESTTMP}/josh-proxy.out +$ cat ${TESTTMP}/josh-proxy.out | grep REPO_UPDATE +$ cat ${TESTTMP}/josh-proxy.out | grep "===" diff --git a/tests/proxy/push_stacked_sub.t b/tests/proxy/push_stacked_sub.t new file mode 100644 index 000000000..7dbfcb682 --- /dev/null +++ b/tests/proxy/push_stacked_sub.t @@ -0,0 +1,164 @@ + $ EXTRA_OPTS=--stacked-changes . ${TESTDIR}/setup_test_env.sh + $ cd ${TESTTMP} + + $ git clone -q http://localhost:8001/real_repo.git 1> /dev/null + warning: You appear to have cloned an empty repository. + $ cd real_repo + + $ mkdir sub1 + $ echo contents1 > sub1/file1 + $ git add sub1 + $ git commit -m "add file1" 1> /dev/null + $ git push 1> /dev/null + To http://localhost:8001/real_repo.git + * [new branch] master -> master + + $ cd ${TESTTMP} + + $ git clone -q http://localhost:8002/real_repo.git:/sub1.git + $ cd sub1 + + $ echo contents2 > file2 + $ git add file2 + $ git commit -m "Change-Id: 1234" 1> /dev/null + $ echo contents2 > file7 + $ git add file7 + $ git commit -m "Change-Id: foo7" 1> /dev/null + $ git log --decorate --graph --pretty="%s %d" + * Change-Id: foo7 (HEAD -> master) + * Change-Id: 1234 + * add file1 (origin/master, origin/HEAD) + $ git push -o author=josh@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @heads/master/josh@example.com + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/master/josh@example.com/1234 + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/master/josh@example.com/foo7 + remote: + remote: + To http://localhost:8002/real_repo.git:/sub1.git + * [new reference] master -> refs/for/master + $ git push -o author=josh@example.com origin master:refs/for/other_branch + remote: josh-proxy + remote: response from upstream: + remote: Reference "refs/heads/other_branch" does not exist on remote. + remote: If you want to create it, pass "-o base=" or "-o base=path/to/ref" + remote: to specify a base branch/reference. + remote: + remote: + remote: + remote: error: hook declined to update refs/for/other_branch + To http://localhost:8002/real_repo.git:/sub1.git + ! [remote rejected] master -> refs/for/other_branch (hook declined) + error: failed to push some refs to 'http://localhost:8002/real_repo.git:/sub1.git' + [1] + $ git push -o base=master -o author=josh@example.com origin master:refs/for/other_branch + remote: josh-proxy + remote: response from upstream: + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @heads/other_branch/josh@example.com + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/other_branch/josh@example.com/1234 + remote: To http://localhost:8001/real_repo.git + remote: * [new branch] JOSH_PUSH -> @changes/other_branch/josh@example.com/foo7 + remote: + remote: + To http://localhost:8002/real_repo.git:/sub1.git + * [new reference] master -> refs/for/other_branch + $ echo contents2 > file3 + $ git add file3 + $ git commit -m "add file3" 1> /dev/null + $ git push -o author=josh@example.com origin master:refs/for/master + remote: josh-proxy + remote: response from upstream: + remote: rejecting to push a3065162ecee0fecc977ec04a275e10b5e15a39c without Change-Id + remote: + remote: + remote: error: hook declined to update refs/for/master + To http://localhost:8002/real_repo.git:/sub1.git + ! [remote rejected] master -> refs/for/master (hook declined) + error: failed to push some refs to 'http://localhost:8002/real_repo.git:/sub1.git' + [1] + + $ curl -s http://localhost:8002/flush + Flushed credential cache + $ git fetch origin + From http://localhost:8002/real_repo.git:/sub1 + * [new branch] @changes/master/josh@example.com/1234 -> origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/foo7 -> origin/@changes/master/josh@example.com/foo7 + * [new branch] @changes/other_branch/josh@example.com/1234 -> origin/@changes/other_branch/josh@example.com/1234 + * [new branch] @changes/other_branch/josh@example.com/foo7 -> origin/@changes/other_branch/josh@example.com/foo7 + * [new branch] @heads/master/josh@example.com -> origin/@heads/master/josh@example.com + * [new branch] @heads/other_branch/josh@example.com -> origin/@heads/other_branch/josh@example.com + $ git log --decorate --graph --pretty="%s %d" + * add file3 (HEAD -> master) + * Change-Id: foo7 (origin/@heads/other_branch/josh@example.com, origin/@heads/master/josh@example.com, origin/@changes/other_branch/josh@example.com/foo7, origin/@changes/master/josh@example.com/foo7) + * Change-Id: 1234 (origin/@changes/other_branch/josh@example.com/1234, origin/@changes/master/josh@example.com/1234) + * add file1 (origin/master, origin/HEAD) + + $ cd ${TESTTMP}/real_repo + $ git fetch origin + From http://localhost:8001/real_repo + * [new branch] @changes/master/josh@example.com/1234 -> origin/@changes/master/josh@example.com/1234 + * [new branch] @changes/master/josh@example.com/foo7 -> origin/@changes/master/josh@example.com/foo7 + * [new branch] @changes/other_branch/josh@example.com/1234 -> origin/@changes/other_branch/josh@example.com/1234 + * [new branch] @changes/other_branch/josh@example.com/foo7 -> origin/@changes/other_branch/josh@example.com/foo7 + * [new branch] @heads/master/josh@example.com -> origin/@heads/master/josh@example.com + * [new branch] @heads/other_branch/josh@example.com -> origin/@heads/other_branch/josh@example.com + $ git checkout -q heads/master/josh@example.com + error: pathspec 'heads/master/josh@example.com' did not match any file(s) known to git + [1] + $ git log --decorate --graph --pretty="%s %d" + * add file1 (HEAD -> master, origin/master) + + $ tree + . + `-- sub1 + `-- file1 + + 1 directory, 1 file + +Make sure all temporary namespace got removed + $ tree ${TESTTMP}/remote/scratch/real_repo.git/refs/ | grep request_ + [1] + + $ bash ${TESTDIR}/destroy_test_env.sh + "real_repo.git" = [':/sub1'] + refs + |-- heads + |-- josh + | |-- filtered + | | `-- real_repo.git + | | `-- %3A%2Fsub1 + | | `-- HEAD + | `-- upstream + | `-- real_repo.git + | |-- HEAD + | `-- refs + | `-- heads + | |-- @changes + | | |-- master + | | | `-- josh@example.com + | | | |-- 1234 + | | | `-- foo7 + | | `-- other_branch + | | `-- josh@example.com + | | |-- 1234 + | | `-- foo7 + | |-- @heads + | | |-- master + | | | `-- josh@example.com + | | `-- other_branch + | | `-- josh@example.com + | `-- master + |-- namespaces + `-- tags + + 19 directories, 9 files + +$ cat ${TESTTMP}/josh-proxy.out +$ cat ${TESTTMP}/josh-proxy.out | grep REPO_UPDATE +$ cat ${TESTTMP}/josh-proxy.out | grep "==="