use crate::common::{
TestRepo, make_snapshot_cmd,
mock_commands::{MockConfig, MockResponse},
repo, setup_snapshot_settings,
};
use ansi_str::AnsiStr;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::path::Path;
use std::process::Command;
fn branch_sha(repo: &TestRepo, branch: &str) -> String {
repo.git_output(&["rev-parse", branch])
}
fn setup_tracking_for_all_branches(repo: &TestRepo, remote: &str) {
for branch in ["feature", "feature-a", "feature-b", "feature-c", "main"] {
repo.run_git(&["config", &format!("branch.{}.remote", branch), remote]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", branch),
&format!("refs/heads/{}", branch),
]);
repo.run_git(&[
"update-ref",
&format!("refs/remotes/{}/{}", remote, branch),
branch,
]);
}
}
fn run_ci_status_test(repo: &mut TestRepo, snapshot_name: &str, pr_json: &str, run_json: &str) {
repo.setup_mock_gh_with_ci_data(pr_json, run_json);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
fn setup_mock_gh_with_api_data(
repo: &TestRepo,
pr_json: &str,
api_responses: &[(&str, &str)],
) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
let mut gh = MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("pr list", MockResponse::output(pr_json));
for (command, response) in api_responses {
gh = gh.command(command, MockResponse::output(response));
}
gh.command("_default", MockResponse::exit(1))
.write(&mock_bin);
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
mock_bin
}
fn configure_mock_ci_env(cmd: &mut Command, mock_bin: &Path) {
cmd.env("MOCK_CONFIG_DIR", mock_bin);
let (path_var_name, current_path) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));
let mut paths: Vec<std::path::PathBuf> = current_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.to_path_buf());
let new_path = std::env::join_paths(&paths).unwrap();
cmd.env(path_var_name, new_path);
}
fn setup_github_repo_with_feature(repo: &mut TestRepo) -> String {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-owner/test-repo.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
#[rstest]
#[case::passed("CLEAN", "COMPLETED", "SUCCESS", "github_pr_passed")]
#[case::failed("BLOCKED", "COMPLETED", "FAILURE", "github_pr_failed")]
#[case::running("UNKNOWN", "IN_PROGRESS", "null", "github_pr_running")]
#[case::conflicts("DIRTY", "COMPLETED", "SUCCESS", "github_pr_conflicts")]
fn test_list_full_with_github_pr_status(
mut repo: TestRepo,
#[case] merge_state: &str,
#[case] status: &str,
#[case] conclusion: &str,
#[case] snapshot_name: &str,
) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let conclusion_json = if conclusion == "null" {
"null".to_string()
} else {
format!("\"{}\"", conclusion)
};
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "{}",
"statusCheckRollup": [
{{"status": "{}", "conclusion": {}}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha, merge_state, status, conclusion_json
);
run_ci_status_test(&mut repo, snapshot_name, &pr_json, "[]");
}
#[rstest]
#[case::pending("UNKNOWN", "PENDING", "status_context_pending")]
#[case::failure("BLOCKED", "FAILURE", "status_context_failure")]
fn test_list_full_with_status_context(
mut repo: TestRepo,
#[case] merge_state: &str,
#[case] state: &str,
#[case] snapshot_name: &str,
) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "{}",
"statusCheckRollup": [
{{"state": "{}"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha, merge_state, state
);
run_ci_status_test(&mut repo, snapshot_name, &pr_json, "[]");
}
#[rstest]
#[case::completed("completed", "success", "github_workflow_run")]
#[case::running("in_progress", "null", "github_workflow_running")]
fn test_list_full_with_github_workflow(
mut repo: TestRepo,
#[case] status: &str,
#[case] conclusion: &str,
#[case] snapshot_name: &str,
) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let conclusion_json = if conclusion == "null" {
"null".to_string()
} else {
format!("\"{}\"", conclusion)
};
let run_json = format!(
r#"[{{
"status": "{}",
"conclusion": {},
"headSha": "{}"
}}]"#,
status, conclusion_json, head_sha
);
run_ci_status_test(&mut repo, snapshot_name, "[]", &run_json);
}
#[rstest]
fn test_list_full_with_stale_pr(mut repo: TestRepo) {
setup_github_repo_with_feature(&mut repo);
let worktree_path = repo.worktrees.get("feature").unwrap().clone();
std::fs::write(worktree_path.join("new_file.txt"), "new content").unwrap();
repo.stage_all(&worktree_path);
repo.run_git_in(&worktree_path, &["commit", "-m", "Local commit"]);
let pr_json = r#"[{
"headRefOid": "old_sha_from_before_local_commit",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{"status": "COMPLETED", "conclusion": "SUCCESS"}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {"login": "test-owner"}
}]"#;
run_ci_status_test(&mut repo, "stale_pr", pr_json, "[]");
}
#[rstest]
fn test_list_full_with_mixed_check_types(mut repo: TestRepo) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "UNKNOWN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}},
{{"state": "PENDING"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "mixed_check_types", &pr_json, "[]");
}
#[rstest]
fn test_list_full_with_no_ci_checks(mut repo: TestRepo) {
let head_sha = setup_github_repo_with_feature(&mut repo);
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "no_ci_checks", &pr_json, "[]");
}
#[rstest]
fn test_list_full_filters_by_repo_owner(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/my-org/test-repo.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
let pr_json = format!(
r#"[
{{
"headRefOid": "wrong_sha",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [{{"status": "COMPLETED", "conclusion": "FAILURE"}}],
"url": "https://github.com/other-org/test-repo/pull/99",
"headRepositoryOwner": {{"login": "other-org"}}
}},
{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [{{"status": "COMPLETED", "conclusion": "SUCCESS"}}],
"url": "https://github.com/my-org/test-repo/pull/1",
"headRepositoryOwner": {{"login": "my-org"}}
}}
]"#,
head_sha
);
run_ci_status_test(&mut repo, "filters_by_repo_owner", &pr_json, "[]");
}
#[rstest]
fn test_list_full_with_platform_override_github(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://bitbucket.org/test-owner/test-repo.git",
]);
repo.run_git(&[
"remote",
"add",
"github",
"https://github.com/test-owner/test-repo.git",
]);
repo.write_project_config(
r#"
[ci]
platform = "github"
"#,
);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "github");
let head_sha = branch_sha(&repo, "feature");
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
let run_json = "[]";
repo.setup_mock_gh_with_ci_data(&pr_json, run_json);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_list_full_with_gitlab_remote(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.example.com/test-owner/test-repo.git",
]);
repo.add_worktree("feature");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_list_full_with_invalid_platform_override(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-owner/test-repo.git",
]);
repo.write_project_config(
r#"
[ci]
platform = "invalid_platform"
"#,
);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/test-owner/test-repo/pull/1",
"headRepositoryOwner": {{"login": "test-owner"}}
}}]"#,
head_sha
);
repo.setup_mock_gh_with_ci_data(&pr_json, "[]");
let mut settings = setup_snapshot_settings(&repo);
settings.add_filter(r"\[[a-zA-Z]\]", "[W]");
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(cmd);
});
}
fn run_gitlab_ci_status_test(
repo: &mut TestRepo,
snapshot_name: &str,
mr_json: &str,
project_id: Option<u64>,
) {
repo.setup_mock_glab_with_ci_data(mr_json, project_id);
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!(snapshot_name, cmd);
});
}
fn setup_gitlab_repo_with_feature(repo: &mut TestRepo) -> String {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/test-group/test-project.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(repo, "origin");
branch_sha(repo, "feature")
}
#[rstest]
#[case::passed("success", false, "gitlab_mr_passed")]
#[case::failed("failed", false, "gitlab_mr_failed")]
#[case::running("running", false, "gitlab_mr_running")]
#[case::pending("pending", false, "gitlab_mr_pending")]
#[case::conflicts("success", true, "gitlab_mr_conflicts")]
fn test_list_full_with_gitlab_mr_status(
mut repo: TestRepo,
#[case] pipeline_status: &str,
#[case] has_conflicts: bool,
#[case] snapshot_name: &str,
) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": {},
"detailed_merge_status": null,
"head_pipeline": {{"status": "{}"}},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha, has_conflicts, pipeline_status
);
run_gitlab_ci_status_test(&mut repo, snapshot_name, &mr_json, Some(12345));
}
#[rstest]
fn test_list_full_with_gitlab_stale_mr(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
let worktree_path = repo.worktrees.get("feature").unwrap().clone();
std::fs::write(worktree_path.join("new_file.txt"), "new content").unwrap();
repo.stage_all(&worktree_path);
repo.run_git_in(&worktree_path, &["commit", "-m", "Local commit"]);
let mr_json = r#"[{
"iid": 1,
"sha": "old_sha_from_before_local_commit",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {"status": "success"},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}]"#;
run_gitlab_ci_status_test(&mut repo, "gitlab_stale_mr", mr_json, Some(12345));
}
#[rstest]
fn test_list_full_with_gitlab_no_ci(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": null,
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha
);
run_gitlab_ci_status_test(&mut repo, "gitlab_no_ci", &mr_json, Some(12345));
}
#[rstest]
fn test_list_full_with_gitlab_filters_by_project_id(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/my-group/my-project.git",
]);
repo.add_worktree("feature");
setup_tracking_for_all_branches(&repo, "origin");
let head_sha = branch_sha(&repo, "feature");
let mr_json = format!(
r#"[
{{
"iid": 99,
"sha": "wrong_sha",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "failed"}},
"source_project_id": 11111,
"web_url": "https://gitlab.com/other-group/other-project/-/merge_requests/99"
}},
{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 99999,
"web_url": "https://gitlab.com/my-group/my-project/-/merge_requests/1"
}}
]"#,
head_sha
);
run_gitlab_ci_status_test(
&mut repo,
"gitlab_filters_by_project_id",
&mr_json,
Some(99999),
);
}
#[rstest]
fn test_list_full_with_gitlab_single_mr_no_project_id(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 12345,
"web_url": "https://gitlab.com/test-group/test-project/-/merge_requests/1"
}}]"#,
head_sha
);
run_gitlab_ci_status_test(&mut repo, "gitlab_single_mr_no_project_id", &mr_json, None);
}
#[rstest]
fn test_list_full_with_gitlab_empty_mr_list_no_project_id(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
run_gitlab_ci_status_test(
&mut repo,
"gitlab_empty_mr_list_no_project_id",
"[]", None, );
}
#[rstest]
fn test_list_full_with_gitlab_multiple_mrs_no_project_id(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_json = format!(
r#"[
{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "failed"}},
"source_project_id": 11111,
"web_url": "https://gitlab.com/org-a/project/-/merge_requests/1"
}},
{{
"iid": 2,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"head_pipeline": {{"status": "success"}},
"source_project_id": 22222,
"web_url": "https://gitlab.com/org-b/project/-/merge_requests/2"
}}
]"#,
head_sha, head_sha
);
run_gitlab_ci_status_test(
&mut repo,
"gitlab_multiple_mrs_no_project_id",
&mr_json,
None,
);
}
#[rstest]
fn test_list_full_with_url_based_pushremote(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/upstream-owner/test-repo.git",
]);
repo.add_worktree("feature");
let head_sha = branch_sha(&repo, "feature");
repo.run_git(&[
"config",
"branch.feature.pushremote",
"https://github.com/fork-owner/test-repo.git", ]);
repo.run_git(&[
"config",
"branch.feature.merge",
"refs/pull/123/head", ]);
let pr_json = format!(
r#"[{{
"headRefOid": "{}",
"mergeStateStatus": "CLEAN",
"statusCheckRollup": [
{{"status": "COMPLETED", "conclusion": "SUCCESS"}}
],
"url": "https://github.com/upstream-owner/test-repo/pull/123",
"headRepositoryOwner": {{"login": "fork-owner"}}
}}]"#,
head_sha
);
run_ci_status_test(&mut repo, "url_based_pushremote", &pr_json, "[]");
}
#[rstest]
fn test_list_full_with_branch_fallback_using_fork_pushremote(mut repo: TestRepo) {
setup_github_repo_with_feature(&mut repo);
let feature_a_sha = branch_sha(&repo, "feature-a");
repo.run_git(&[
"config",
"branch.feature-a.pushremote",
"https://github.com/fork-owner/test-repo.git",
]);
let fork_checks = r#"[{"status":"COMPLETED","conclusion":"SUCCESS"}]"#;
let mock_bin = setup_mock_gh_with_api_data(
&repo,
"[]",
&[
(
&format!("api repos/upstream-owner/test-repo/commits/{feature_a_sha}/check-runs"),
"[]",
),
(
&format!("api repos/fork-owner/test-repo/commits/{feature_a_sha}/check-runs"),
fork_checks,
),
],
);
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
configure_mock_ci_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt list should succeed");
let stdout = String::from_utf8_lossy(&output.stdout)
.ansi_strip()
.into_owned();
let feature_a_line = stdout
.lines()
.find(|line| line.contains("feature-a"))
.expect("expected feature-a line in wt list output");
assert!(
feature_a_line.contains("●"),
"expected feature-a to show passed branch CI from the fork repo fallback\nstdout:\n{stdout}",
);
}
#[rstest]
fn test_list_full_with_gitlab_mr_view_failure(mut repo: TestRepo) {
let head_sha = setup_gitlab_repo_with_feature(&mut repo);
let mr_list_json = format!(
r#"[{{
"iid": 1,
"sha": "{}",
"has_conflicts": false,
"detailed_merge_status": null,
"source_project_id": 12345,
"web_url": "https://gitlab.com/test/repo/-/merge_requests/1"
}}]"#,
head_sha
);
repo.setup_mock_glab_with_failing_mr_view(&mr_list_json, Some(12345));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitlab_mr_view_failure", cmd);
});
}
#[rstest]
fn test_list_full_with_gitlab_ci_rate_limit(mut repo: TestRepo) {
setup_gitlab_repo_with_feature(&mut repo);
repo.setup_mock_glab_with_ci_rate_limit(Some(12345));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &["--full"], None);
repo.configure_mock_commands(&mut cmd);
assert_cmd_snapshot!("gitlab_ci_rate_limit", cmd);
});
}