47dcdd3c
Add end-to-end server coverage and refine the server workflows
a73x 2026-04-03 17:23
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -57,6 +57,19 @@ install-man: man | |||
| 57 | clean-man: | 57 | clean-man: |
| 58 | rm -rf man/ | 58 | rm -rf man/ |
| 59 | 59 | ||
| 60 | # --- Docker --- | ||
| 61 | REGISTRY := registry.a73x.sh | ||
| 62 | IMAGE := $(REGISTRY)/git-collab-server | ||
| 63 | TAG ?= 0.0.1 | ||
| 64 | |||
| 65 | .PHONY: docker docker-push | ||
| 66 | |||
| 67 | docker: | ||
| 68 | docker build -t $(IMAGE):$(TAG) . | ||
| 69 | |||
| 70 | docker-push: docker | ||
| 71 | docker push $(IMAGE):$(TAG) | ||
| 72 | |||
| 60 | # --- Dev helpers --- | 73 | # --- Dev helpers --- |
| 61 | .PHONY: run serve dev dashboard | 74 | .PHONY: run serve dev dashboard |
| 62 | 75 | ||
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,7 @@ pub fn router(state: AppState) -> Router { | |||
| 19 | .route("/", axum::routing::get(repo_list::handler)) | 19 | .route("/", axum::routing::get(repo_list::handler)) |
| 20 | .route("/{repo_name}", axum::routing::get(repo::overview)) | 20 | .route("/{repo_name}", axum::routing::get(repo::overview)) |
| 21 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) | 21 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) |
| 22 | .route("/{repo_name}/commits/{ref_name}", axum::routing::get(repo::commits_ref)) | ||
| 22 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) | 23 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) |
| 23 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) | 24 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) |
| 24 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) | 25 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) |
src/server/http/repo/commits.rs
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ use std::sync::Arc; | |||
| 3 | use axum::extract::{Path, State}; | 3 | use axum::extract::{Path, State}; |
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; | 6 | use super::{AppState, OverviewCommit, collab_counts, head_branch_name, list_branches, open_repo}; |
| 7 | 7 | ||
| 8 | #[derive(askama::Template, askama_web::WebTemplate)] | 8 | #[derive(askama::Template, askama_web::WebTemplate)] |
| 9 | #[template(path = "commits.html")] | 9 | #[template(path = "commits.html")] |
| @@ -13,9 +13,46 @@ pub struct CommitsTemplate { | |||
| 13 | pub active_section: String, | 13 | pub active_section: String, |
| 14 | pub open_patches: usize, | 14 | pub open_patches: usize, |
| 15 | pub open_issues: usize, | 15 | pub open_issues: usize, |
| 16 | pub ref_name: String, | ||
| 17 | pub branches: Vec<String>, | ||
| 16 | pub commits: Vec<OverviewCommit>, | 18 | pub commits: Vec<OverviewCommit>, |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 21 | fn commits_for_ref(repo: &git2::Repository, ref_name: &str, limit: usize) -> Vec<OverviewCommit> { | ||
| 22 | let obj = match repo.revparse_single(ref_name) { | ||
| 23 | Ok(o) => o, | ||
| 24 | Err(_) => return Vec::new(), | ||
| 25 | }; | ||
| 26 | let commit = match obj.peel_to_commit() { | ||
| 27 | Ok(c) => c, | ||
| 28 | Err(_) => return Vec::new(), | ||
| 29 | }; | ||
| 30 | let mut revwalk = match repo.revwalk() { | ||
| 31 | Ok(rw) => rw, | ||
| 32 | Err(_) => return Vec::new(), | ||
| 33 | }; | ||
| 34 | if revwalk.push(commit.id()).is_err() { | ||
| 35 | return Vec::new(); | ||
| 36 | } | ||
| 37 | revwalk | ||
| 38 | .take(limit) | ||
| 39 | .filter_map(|oid| { | ||
| 40 | let oid = oid.ok()?; | ||
| 41 | let commit = repo.find_commit(oid).ok()?; | ||
| 42 | let id = oid.to_string(); | ||
| 43 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 44 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 45 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 46 | let secs = commit.time().seconds(); | ||
| 47 | let date = chrono::TimeZone::timestamp_opt(&chrono::Utc, secs, 0) | ||
| 48 | .single() | ||
| 49 | .map(|dt| dt.format("%Y-%m-%d").to_string()) | ||
| 50 | .unwrap_or_default(); | ||
| 51 | Some(OverviewCommit { id, short_id, summary, author, date }) | ||
| 52 | }) | ||
| 53 | .collect() | ||
| 54 | } | ||
| 55 | |||
| 19 | pub async fn commits( | 56 | pub async fn commits( |
| 20 | Path(repo_name): Path<String>, | 57 | Path(repo_name): Path<String>, |
| 21 | State(state): State<Arc<AppState>>, | 58 | State(state): State<Arc<AppState>>, |
| @@ -25,7 +62,35 @@ pub async fn commits( | |||
| 25 | Err(resp) => return resp, | 62 | Err(resp) => return resp, |
| 26 | }; | 63 | }; |
| 27 | 64 | ||
| 28 | let commits = recent_commits(&repo, 200); | 65 | let ref_name = head_branch_name(&repo); |
| 66 | let branches = list_branches(&repo); | ||
| 67 | let commits = commits_for_ref(&repo, &ref_name, 200); | ||
| 68 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 69 | |||
| 70 | CommitsTemplate { | ||
| 71 | site_title: state.site_title.clone(), | ||
| 72 | repo_name, | ||
| 73 | active_section: "commits".to_string(), | ||
| 74 | open_patches, | ||
| 75 | open_issues, | ||
| 76 | ref_name, | ||
| 77 | branches, | ||
| 78 | commits, | ||
| 79 | } | ||
| 80 | .into_response() | ||
| 81 | } | ||
| 82 | |||
| 83 | pub async fn commits_ref( | ||
| 84 | Path((repo_name, ref_name)): Path<(String, String)>, | ||
| 85 | State(state): State<Arc<AppState>>, | ||
| 86 | ) -> Response { | ||
| 87 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 88 | Ok(r) => r, | ||
| 89 | Err(resp) => return resp, | ||
| 90 | }; | ||
| 91 | |||
| 92 | let branches = list_branches(&repo); | ||
| 93 | let commits = commits_for_ref(&repo, &ref_name, 200); | ||
| 29 | let (open_patches, open_issues) = collab_counts(&repo); | 94 | let (open_patches, open_issues) = collab_counts(&repo); |
| 30 | 95 | ||
| 31 | CommitsTemplate { | 96 | CommitsTemplate { |
| @@ -34,6 +99,8 @@ pub async fn commits( | |||
| 34 | active_section: "commits".to_string(), | 99 | active_section: "commits".to_string(), |
| 35 | open_patches, | 100 | open_patches, |
| 36 | open_issues, | 101 | open_issues, |
| 102 | ref_name, | ||
| 103 | branches, | ||
| 37 | commits, | 104 | commits, |
| 38 | } | 105 | } |
| 39 | .into_response() | 106 | .into_response() |
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,7 @@ mod patches; | |||
| 6 | mod issues; | 6 | mod issues; |
| 7 | 7 | ||
| 8 | pub use overview::overview; | 8 | pub use overview::overview; |
| 9 | pub use commits::commits; | 9 | pub use commits::{commits, commits_ref}; |
| 10 | pub use tree::{tree_root, tree, blob}; | 10 | pub use tree::{tree_root, tree, blob}; |
| 11 | pub use diff::diff; | 11 | pub use diff::diff; |
| 12 | pub use patches::{patches, patch_detail}; | 12 | pub use patches::{patches, patch_detail}; |
| @@ -82,8 +82,16 @@ fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> | |||
| 82 | Err(_) => return Vec::new(), | 82 | Err(_) => return Vec::new(), |
| 83 | }; | 83 | }; |
| 84 | 84 | ||
| 85 | // Try HEAD first; if unborn, fall back to the first local branch | ||
| 85 | if revwalk.push_head().is_err() { | 86 | if revwalk.push_head().is_err() { |
| 86 | return Vec::new(); | 87 | let branch = list_branches(repo).into_iter().next(); |
| 88 | let pushed = branch.and_then(|name| { | ||
| 89 | let obj = repo.revparse_single(&name).ok()?; | ||
| 90 | revwalk.push(obj.id()).ok() | ||
| 91 | }); | ||
| 92 | if pushed.is_none() { | ||
| 93 | return Vec::new(); | ||
| 94 | } | ||
| 87 | } | 95 | } |
| 88 | 96 | ||
| 89 | revwalk | 97 | revwalk |
| @@ -113,6 +121,34 @@ pub struct CommentView { | |||
| 113 | pub timestamp: String, | 121 | pub timestamp: String, |
| 114 | } | 122 | } |
| 115 | 123 | ||
| 124 | /// List local branch names, sorted alphabetically. | ||
| 125 | fn list_branches(repo: &git2::Repository) -> Vec<String> { | ||
| 126 | let mut names: Vec<String> = repo | ||
| 127 | .branches(Some(git2::BranchType::Local)) | ||
| 128 | .into_iter() | ||
| 129 | .flatten() | ||
| 130 | .filter_map(|b| { | ||
| 131 | let (branch, _) = b.ok()?; | ||
| 132 | branch.name().ok()?.map(|n| n.to_string()) | ||
| 133 | }) | ||
| 134 | .collect(); | ||
| 135 | names.sort(); | ||
| 136 | names | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Resolve HEAD to its short branch name (e.g. "main"). | ||
| 140 | /// Falls back to the first existing branch if HEAD points to an unborn branch, | ||
| 141 | /// which happens when a bare repo is init'd with master but only main is pushed. | ||
| 142 | fn head_branch_name(repo: &git2::Repository) -> String { | ||
| 143 | if let Ok(head) = repo.head() { | ||
| 144 | if let Some(name) = head.shorthand() { | ||
| 145 | return name.to_string(); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | // HEAD is unborn or detached — pick the first local branch | ||
| 149 | list_branches(repo).into_iter().next().unwrap_or_else(|| "HEAD".to_string()) | ||
| 150 | } | ||
| 151 | |||
| 116 | /// Open a repo by name, returning the entry and git2::Repository or an error Response. | 152 | /// Open a repo by name, returning the entry and git2::Repository or an error Response. |
| 117 | #[allow(clippy::result_large_err)] | 153 | #[allow(clippy::result_large_err)] |
| 118 | fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> { | 154 | fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> { |
src/server/http/repo/tree.rs
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ use std::sync::Arc; | |||
| 3 | use axum::extract::{Path, State}; | 3 | use axum::extract::{Path, State}; |
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{AppState, collab_counts, internal_error, not_found, open_repo}; | 6 | use super::{AppState, collab_counts, head_branch_name, internal_error, list_branches, not_found, open_repo}; |
| 7 | 7 | ||
| 8 | #[derive(Debug)] | 8 | #[derive(Debug)] |
| 9 | pub struct TreeEntry { | 9 | pub struct TreeEntry { |
| @@ -21,6 +21,7 @@ pub struct TreeTemplate { | |||
| 21 | pub open_patches: usize, | 21 | pub open_patches: usize, |
| 22 | pub open_issues: usize, | 22 | pub open_issues: usize, |
| 23 | pub ref_name: String, | 23 | pub ref_name: String, |
| 24 | pub branches: Vec<String>, | ||
| 24 | pub path_display: String, | 25 | pub path_display: String, |
| 25 | pub show_parent: bool, | 26 | pub show_parent: bool, |
| 26 | pub parent_path: String, | 27 | pub parent_path: String, |
| @@ -36,6 +37,7 @@ pub struct BlobTemplate { | |||
| 36 | pub open_patches: usize, | 37 | pub open_patches: usize, |
| 37 | pub open_issues: usize, | 38 | pub open_issues: usize, |
| 38 | pub ref_name: String, | 39 | pub ref_name: String, |
| 40 | pub branches: Vec<String>, | ||
| 39 | pub file_path: String, | 41 | pub file_path: String, |
| 40 | pub content: String, | 42 | pub content: String, |
| 41 | pub is_binary: bool, | 43 | pub is_binary: bool, |
| @@ -62,6 +64,8 @@ fn build_tree_response( | |||
| 62 | path: String, | 64 | path: String, |
| 63 | ) -> Response { | 65 | ) -> Response { |
| 64 | let (open_patches, open_issues) = collab_counts(repo); | 66 | let (open_patches, open_issues) = collab_counts(repo); |
| 67 | let branches = list_branches(repo); | ||
| 68 | let ref_name = if ref_name == "HEAD" { head_branch_name(repo) } else { ref_name }; | ||
| 65 | 69 | ||
| 66 | let obj = match repo.revparse_single(&ref_name) { | 70 | let obj = match repo.revparse_single(&ref_name) { |
| 67 | Ok(o) => o, | 71 | Ok(o) => o, |
| @@ -131,6 +135,7 @@ fn build_tree_response( | |||
| 131 | open_patches, | 135 | open_patches, |
| 132 | open_issues, | 136 | open_issues, |
| 133 | ref_name, | 137 | ref_name, |
| 138 | branches, | ||
| 134 | path_display: path, | 139 | path_display: path, |
| 135 | show_parent, | 140 | show_parent, |
| 136 | parent_path, | 141 | parent_path, |
| @@ -174,7 +179,9 @@ pub async fn blob( | |||
| 174 | }; | 179 | }; |
| 175 | 180 | ||
| 176 | let (open_patches, open_issues) = collab_counts(&repo); | 181 | let (open_patches, open_issues) = collab_counts(&repo); |
| 182 | let branches = list_branches(&repo); | ||
| 177 | let (ref_name, file_path) = split_ref_path(&rest); | 183 | let (ref_name, file_path) = split_ref_path(&rest); |
| 184 | let ref_name = if ref_name == "HEAD" { head_branch_name(&repo) } else { ref_name }; | ||
| 178 | 185 | ||
| 179 | let obj = match repo.revparse_single(&ref_name) { | 186 | let obj = match repo.revparse_single(&ref_name) { |
| 180 | Ok(o) => o, | 187 | Ok(o) => o, |
| @@ -229,6 +236,7 @@ pub async fn blob( | |||
| 229 | open_patches, | 236 | open_patches, |
| 230 | open_issues, | 237 | open_issues, |
| 231 | ref_name, | 238 | ref_name, |
| 239 | branches, | ||
| 232 | file_path, | 240 | file_path, |
| 233 | content, | 241 | content, |
| 234 | is_binary, | 242 | is_binary, |
src/server/http/templates/blob.html
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,13 @@ | |||
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>{{ file_path }}</h2> | 6 | <h2>{{ file_path }}</h2> |
| 7 | <p style="color: #666;">Ref: <span class="mono">{{ ref_name }}</span> Size: {{ size_display }}</p> | 7 | <p style="color: #666;">Ref: |
| 8 | <select class="mono" onchange="location.href='/{{ repo_name }}/blob/' + this.value + '/{{ file_path }}'"> | ||
| 9 | {% for b in branches %} | ||
| 10 | <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> | ||
| 11 | {% endfor %} | ||
| 12 | </select> | ||
| 13 | Size: {{ size_display }}</p> | ||
| 8 | {% if is_binary %} | 14 | {% if is_binary %} |
| 9 | <p style="color: #666; font-style: italic;">Binary file</p> | 15 | <p style="color: #666; font-style: italic;">Binary file</p> |
| 10 | {% else %} | 16 | {% else %} |
src/server/http/templates/commits.html
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,13 @@ | |||
| 3 | {% block title %}Commits — {{ repo_name }} — {{ site_title }}{% endblock %} | 3 | {% block title %}Commits — {{ repo_name }} — {{ site_title }}{% endblock %} |
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>Commits</h2> | 6 | <h2>Commits: |
| 7 | <select onchange="location.href='/{{ repo_name }}/commits/' + this.value"> | ||
| 8 | {% for b in branches %} | ||
| 9 | <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> | ||
| 10 | {% endfor %} | ||
| 11 | </select> | ||
| 12 | </h2> | ||
| 7 | {% if commits.is_empty() %} | 13 | {% if commits.is_empty() %} |
| 8 | <p style="color: #666;">No commits yet.</p> | 14 | <p style="color: #666;">No commits yet.</p> |
| 9 | {% else %} | 15 | {% else %} |
src/server/http/templates/tree.html
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,14 @@ | |||
| 3 | {% block title %}Tree — {{ repo_name }} — {{ site_title }}{% endblock %} | 3 | {% block title %}Tree — {{ repo_name }} — {{ site_title }}{% endblock %} |
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>Tree: {{ ref_name }}{% if !path_display.is_empty() %} / {{ path_display }}{% endif %}</h2> | 6 | <h2> |
| 7 | <select onchange="location.href='/{{ repo_name }}/tree/' + this.value + '{% if !path_display.is_empty() %}/{{ path_display }}{% endif %}'"> | ||
| 8 | {% for b in branches %} | ||
| 9 | <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> | ||
| 10 | {% endfor %} | ||
| 11 | </select> | ||
| 12 | {% if !path_display.is_empty() %} / {{ path_display }}{% endif %} | ||
| 13 | </h2> | ||
| 7 | <table> | 14 | <table> |
| 8 | <thead> | 15 | <thead> |
| 9 | <tr> | 16 | <tr> |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -94,6 +94,24 @@ pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { | |||
| 94 | Some(full) | 94 | Some(full) |
| 95 | } | 95 | } |
| 96 | 96 | ||
| 97 | fn ensure_repo_exists_for_command(git_cmd: &str, repo_path: &Path) -> Result<bool, git2::Error> { | ||
| 98 | if repo_path.exists() { | ||
| 99 | return Ok(false); | ||
| 100 | } | ||
| 101 | |||
| 102 | if git_cmd != "git-receive-pack" { | ||
| 103 | return Ok(false); | ||
| 104 | } | ||
| 105 | |||
| 106 | if let Some(parent) = repo_path.parent() { | ||
| 107 | std::fs::create_dir_all(parent) | ||
| 108 | .map_err(|e| git2::Error::from_str(&format!("failed to create repo parent dir: {e}")))?; | ||
| 109 | } | ||
| 110 | |||
| 111 | git2::Repository::init_bare(repo_path)?; | ||
| 112 | Ok(true) | ||
| 113 | } | ||
| 114 | |||
| 97 | #[async_trait] | 115 | #[async_trait] |
| 98 | impl Handler for SshHandler { | 116 | impl Handler for SshHandler { |
| 99 | type Error = russh::Error; | 117 | type Error = russh::Error; |
| @@ -183,11 +201,25 @@ impl Handler for SshHandler { | |||
| 183 | }; | 201 | }; |
| 184 | 202 | ||
| 185 | if !resolved_path.exists() { | 203 | if !resolved_path.exists() { |
| 186 | warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); | 204 | match ensure_repo_exists_for_command(&git_cmd, &resolved_path) { |
| 187 | session.exit_status_request(channel, 1); | 205 | Ok(true) => { |
| 188 | session.eof(channel); | 206 | info!("Created bare repo for receive-pack: {:?}", resolved_path); |
| 189 | session.close(channel); | 207 | } |
| 190 | return Ok(()); | 208 | Ok(false) => { |
| 209 | warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); | ||
| 210 | session.exit_status_request(channel, 1); | ||
| 211 | session.eof(channel); | ||
| 212 | session.close(channel); | ||
| 213 | return Ok(()); | ||
| 214 | } | ||
| 215 | Err(e) => { | ||
| 216 | error!("Failed to create repo {:?}: {}", resolved_path, e); | ||
| 217 | session.exit_status_request(channel, 1); | ||
| 218 | session.eof(channel); | ||
| 219 | session.close(channel); | ||
| 220 | return Ok(()); | ||
| 221 | } | ||
| 222 | } | ||
| 191 | } | 223 | } |
| 192 | 224 | ||
| 193 | // Create a channel for forwarding client stdin data to the git subprocess | 225 | // Create a channel for forwarding client stdin data to the git subprocess |
| @@ -279,6 +311,7 @@ async fn run_git_command( | |||
| 279 | #[cfg(test)] | 311 | #[cfg(test)] |
| 280 | mod tests { | 312 | mod tests { |
| 281 | use super::*; | 313 | use super::*; |
| 314 | use tempfile::TempDir; | ||
| 282 | 315 | ||
| 283 | #[test] | 316 | #[test] |
| 284 | fn parse_upload_pack() { | 317 | fn parse_upload_pack() { |
| @@ -339,4 +372,28 @@ mod tests { | |||
| 339 | let result = resolve_repo_path(repos_dir, "myrepo.git"); | 372 | let result = resolve_repo_path(repos_dir, "myrepo.git"); |
| 340 | assert_eq!(result, Some(PathBuf::from("/srv/git/myrepo.git"))); | 373 | assert_eq!(result, Some(PathBuf::from("/srv/git/myrepo.git"))); |
| 341 | } | 374 | } |
| 375 | |||
| 376 | #[test] | ||
| 377 | fn receive_pack_creates_missing_bare_repo() { | ||
| 378 | let tmp = TempDir::new().unwrap(); | ||
| 379 | let repo_path = tmp.path().join("org").join("new-repo.git"); | ||
| 380 | |||
| 381 | let created = ensure_repo_exists_for_command("git-receive-pack", &repo_path).unwrap(); | ||
| 382 | |||
| 383 | assert!(created); | ||
| 384 | assert!(repo_path.exists()); | ||
| 385 | let repo = git2::Repository::open_bare(&repo_path).unwrap(); | ||
| 386 | assert!(repo.is_bare()); | ||
| 387 | } | ||
| 388 | |||
| 389 | #[test] | ||
| 390 | fn upload_pack_does_not_create_missing_repo() { | ||
| 391 | let tmp = TempDir::new().unwrap(); | ||
| 392 | let repo_path = tmp.path().join("org").join("missing.git"); | ||
| 393 | |||
| 394 | let created = ensure_repo_exists_for_command("git-upload-pack", &repo_path).unwrap(); | ||
| 395 | |||
| 396 | assert!(!created); | ||
| 397 | assert!(!repo_path.exists()); | ||
| 398 | } | ||
| 342 | } | 399 | } |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -593,16 +593,23 @@ fn resolve_ref( | |||
| 593 | singular: &str, | 593 | singular: &str, |
| 594 | prefix: &str, | 594 | prefix: &str, |
| 595 | ) -> Result<(String, String), crate::error::Error> { | 595 | ) -> Result<(String, String), crate::error::Error> { |
| 596 | let mut matches: Vec<_> = collab_refs(repo, kind)? | 596 | let active: Vec<_> = collab_refs(repo, kind)? |
| 597 | .into_iter() | 597 | .into_iter() |
| 598 | .filter(|(_, id)| id.starts_with(prefix)) | 598 | .filter(|(_, id)| id.starts_with(prefix)) |
| 599 | .collect(); | 599 | .collect(); |
| 600 | // Also search archive namespace | 600 | let archived: Vec<_> = collab_archive_refs(repo, kind)? |
| 601 | let archive_matches: Vec<_> = collab_archive_refs(repo, kind)? | ||
| 602 | .into_iter() | 601 | .into_iter() |
| 603 | .filter(|(_, id)| id.starts_with(prefix)) | 602 | .filter(|(_, id)| id.starts_with(prefix)) |
| 604 | .collect(); | 603 | .collect(); |
| 605 | matches.extend(archive_matches); | 604 | |
| 605 | // Deduplicate: if the same ID appears in both active and archive, prefer archive | ||
| 606 | let mut seen = std::collections::HashSet::new(); | ||
| 607 | let mut matches = Vec::new(); | ||
| 608 | for entry in archived.into_iter().chain(active.into_iter()) { | ||
| 609 | if seen.insert(entry.1.clone()) { | ||
| 610 | matches.push(entry); | ||
| 611 | } | ||
| 612 | } | ||
| 606 | 613 | ||
| 607 | match matches.len() { | 614 | match matches.len() { |
| 608 | 0 => Err( | 615 | 0 => Err( |
| @@ -619,49 +626,78 @@ fn resolve_ref( | |||
| 619 | } | 626 | } |
| 620 | } | 627 | } |
| 621 | 628 | ||
| 622 | /// List all issue refs and return their materialized state. | 629 | /// List active issue refs, excluding any that also have an archived ref. |
| 623 | pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { | 630 | pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { |
| 631 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "issues")? | ||
| 632 | .into_iter() | ||
| 633 | .map(|(_, id)| id) | ||
| 634 | .collect(); | ||
| 624 | let items = collab_refs(repo, "issues")? | 635 | let items = collab_refs(repo, "issues")? |
| 625 | .into_iter() | 636 | .into_iter() |
| 637 | .filter(|(_, id)| !archived_ids.contains(id)) | ||
| 626 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) | 638 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) |
| 627 | .collect(); | 639 | .collect(); |
| 628 | Ok(items) | 640 | Ok(items) |
| 629 | } | 641 | } |
| 630 | 642 | ||
| 631 | /// List all patch refs and return their materialized state. | 643 | /// List active patch refs, excluding any that also have an archived ref. |
| 632 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { | 644 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { |
| 645 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? | ||
| 646 | .into_iter() | ||
| 647 | .map(|(_, id)| id) | ||
| 648 | .collect(); | ||
| 633 | let items = collab_refs(repo, "patches")? | 649 | let items = collab_refs(repo, "patches")? |
| 634 | .into_iter() | 650 | .into_iter() |
| 651 | .filter(|(_, id)| !archived_ids.contains(id)) | ||
| 635 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) | 652 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) |
| 636 | .collect(); | 653 | .collect(); |
| 637 | Ok(items) | 654 | Ok(items) |
| 638 | } | 655 | } |
| 639 | 656 | ||
| 640 | /// List all issue refs (active + archived) and return their materialized state. | 657 | /// List all issue refs (active + archived) and return their materialized state. |
| 658 | /// Deduplicates by ID, preferring the archived version (which has the final state). | ||
| 641 | pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { | 659 | pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { |
| 642 | let mut items: Vec<_> = collab_refs(repo, "issues")? | 660 | let mut seen = std::collections::HashSet::new(); |
| 643 | .into_iter() | 661 | let mut items = Vec::new(); |
| 644 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) | 662 | |
| 645 | .collect(); | 663 | // Archived first so they take priority |
| 646 | let archived: Vec<_> = collab_archive_refs(repo, "issues")? | 664 | for (ref_name, id) in collab_archive_refs(repo, "issues")? { |
| 647 | .into_iter() | 665 | if seen.insert(id.clone()) { |
| 648 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) | 666 | if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { |
| 649 | .collect(); | 667 | items.push(state); |
| 650 | items.extend(archived); | 668 | } |
| 669 | } | ||
| 670 | } | ||
| 671 | for (ref_name, id) in collab_refs(repo, "issues")? { | ||
| 672 | if seen.insert(id.clone()) { | ||
| 673 | if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { | ||
| 674 | items.push(state); | ||
| 675 | } | ||
| 676 | } | ||
| 677 | } | ||
| 651 | Ok(items) | 678 | Ok(items) |
| 652 | } | 679 | } |
| 653 | 680 | ||
| 654 | /// List all patch refs (active + archived) and return their materialized state. | 681 | /// List all patch refs (active + archived) and return their materialized state. |
| 682 | /// Deduplicates by ID, preferring the archived version (which has the final state). | ||
| 655 | pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { | 683 | pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { |
| 656 | let mut items: Vec<_> = collab_refs(repo, "patches")? | 684 | let mut seen = std::collections::HashSet::new(); |
| 657 | .into_iter() | 685 | let mut items = Vec::new(); |
| 658 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) | 686 | |
| 659 | .collect(); | 687 | for (ref_name, id) in collab_archive_refs(repo, "patches")? { |
| 660 | let archived: Vec<_> = collab_archive_refs(repo, "patches")? | 688 | if seen.insert(id.clone()) { |
| 661 | .into_iter() | 689 | if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { |
| 662 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) | 690 | items.push(state); |
| 663 | .collect(); | 691 | } |
| 664 | items.extend(archived); | 692 | } |
| 693 | } | ||
| 694 | for (ref_name, id) in collab_refs(repo, "patches")? { | ||
| 695 | if seen.insert(id.clone()) { | ||
| 696 | if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { | ||
| 697 | items.push(state); | ||
| 698 | } | ||
| 699 | } | ||
| 700 | } | ||
| 665 | Ok(items) | 701 | Ok(items) |
| 666 | } | 702 | } |
| 667 | 703 | ||
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -618,6 +618,43 @@ fn test_init_no_remotes() { | |||
| 618 | } | 618 | } |
| 619 | 619 | ||
| 620 | // =========================================================================== | 620 | // =========================================================================== |
| 621 | // Top-level status/log commands | ||
| 622 | // =========================================================================== | ||
| 623 | |||
| 624 | #[test] | ||
| 625 | fn test_status_command_reports_summary_and_recent_items() { | ||
| 626 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 627 | repo.issue_open("Status bug"); | ||
| 628 | repo.patch_create("Status patch"); | ||
| 629 | |||
| 630 | let out = repo.run_ok(&["status"]); | ||
| 631 | assert!(out.contains("Issues: 1 open, 0 closed"), "unexpected status output: {}", out); | ||
| 632 | assert!(out.contains("Patches: 1 open, 0 merged, 0 closed"), "unexpected status output: {}", out); | ||
| 633 | assert!(out.contains("Recently updated:"), "unexpected status output: {}", out); | ||
| 634 | assert!(out.contains("[issue]") && out.contains("Status bug"), "unexpected status output: {}", out); | ||
| 635 | assert!(out.contains("[patch]") && out.contains("Status patch"), "unexpected status output: {}", out); | ||
| 636 | } | ||
| 637 | |||
| 638 | #[test] | ||
| 639 | fn test_log_command_shows_recent_collab_events_and_limit() { | ||
| 640 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 641 | let issue_id = repo.issue_open("Logged issue"); | ||
| 642 | repo.run_ok(&["issue", "comment", &issue_id, "-b", "Logged comment"]); | ||
| 643 | repo.patch_create("Logged patch"); | ||
| 644 | |||
| 645 | let out = repo.run_ok(&["log"]); | ||
| 646 | assert!(out.contains("IssueOpen issue"), "unexpected log output: {}", out); | ||
| 647 | assert!(out.contains("IssueComment issue"), "unexpected log output: {}", out); | ||
| 648 | assert!(out.contains("PatchCreate patch"), "unexpected log output: {}", out); | ||
| 649 | assert!(out.contains("open \"Logged issue\""), "unexpected log output: {}", out); | ||
| 650 | assert!(out.contains("Logged comment"), "unexpected log output: {}", out); | ||
| 651 | assert!(out.contains("create \"Logged patch\""), "unexpected log output: {}", out); | ||
| 652 | |||
| 653 | let limited = repo.run_ok(&["log", "-n", "2"]); | ||
| 654 | assert_eq!(limited.lines().count(), 2, "expected exactly 2 log lines, got: {}", limited); | ||
| 655 | } | ||
| 656 | |||
| 657 | // =========================================================================== | ||
| 621 | // Full scenario tests | 658 | // Full scenario tests |
| 622 | // =========================================================================== | 659 | // =========================================================================== |
| 623 | 660 | ||
| @@ -881,3 +918,41 @@ fn test_patch_delete_then_show_errors() { | |||
| 881 | repo.run_ok(&["patch", "delete", &id]); | 918 | repo.run_ok(&["patch", "delete", &id]); |
| 882 | repo.run_err(&["patch", "show", &id]); | 919 | repo.run_err(&["patch", "show", &id]); |
| 883 | } | 920 | } |
| 921 | |||
| 922 | // =========================================================================== | ||
| 923 | // Dashboard smoke | ||
| 924 | // =========================================================================== | ||
| 925 | |||
| 926 | #[test] | ||
| 927 | fn test_dashboard_smoke_launches_and_quits_cleanly() { | ||
| 928 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 929 | |||
| 930 | let output = repo.run_dashboard_smoke("q"); | ||
| 931 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 932 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 933 | |||
| 934 | assert!( | ||
| 935 | output.status.success(), | ||
| 936 | "dashboard should exit cleanly\nstdout: {}\nstderr: {}", | ||
| 937 | stdout, | ||
| 938 | stderr | ||
| 939 | ); | ||
| 940 | } | ||
| 941 | |||
| 942 | #[test] | ||
| 943 | fn test_dashboard_smoke_with_seeded_collab_data_exits_cleanly() { | ||
| 944 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 945 | repo.issue_open("Dashboard issue"); | ||
| 946 | repo.patch_create("Dashboard patch"); | ||
| 947 | |||
| 948 | let output = repo.run_dashboard_smoke("q"); | ||
| 949 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 950 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 951 | |||
| 952 | assert!( | ||
| 953 | output.status.success(), | ||
| 954 | "dashboard with seeded data should exit cleanly\nstdout: {}\nstderr: {}", | ||
| 955 | stdout, | ||
| 956 | stderr | ||
| 957 | ); | ||
| 958 | } | ||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,14 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | use std::path::Path; | 3 | use std::env; |
| 4 | use std::process::{Command, Output}; | 4 | use std::ffi::OsString; |
| 5 | use std::io::{Read, Write}; | ||
| 6 | use std::net::{SocketAddr, TcpListener, TcpStream}; | ||
| 7 | use std::path::{Path, PathBuf}; | ||
| 8 | use std::process::{Child, Command, Output, Stdio}; | ||
| 9 | use std::sync::{Mutex, MutexGuard, OnceLock}; | ||
| 10 | use std::thread; | ||
| 11 | use std::time::{Duration, Instant}; | ||
| 5 | 12 | ||
| 6 | use ed25519_dalek::SigningKey; | 13 | use ed25519_dalek::SigningKey; |
| 7 | use git2::Repository; | 14 | use git2::Repository; |
| @@ -45,6 +52,127 @@ pub fn setup_signing_key(config_dir: &Path) { | |||
| 45 | git_collab::signing::generate_keypair(config_dir).expect("generate test keypair"); | 52 | git_collab::signing::generate_keypair(config_dir).expect("generate test keypair"); |
| 46 | } | 53 | } |
| 47 | 54 | ||
| 55 | fn test_env_lock() -> &'static Mutex<()> { | ||
| 56 | static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); | ||
| 57 | LOCK.get_or_init(|| Mutex::new(())) | ||
| 58 | } | ||
| 59 | |||
| 60 | fn set_or_remove_env(key: &str, value: Option<OsString>) { | ||
| 61 | match value { | ||
| 62 | Some(value) => env::set_var(key, value), | ||
| 63 | None => env::remove_var(key), | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | pub struct TestHome { | ||
| 68 | root: TempDir, | ||
| 69 | home_dir: PathBuf, | ||
| 70 | xdg_config_home: PathBuf, | ||
| 71 | git_config_global: PathBuf, | ||
| 72 | } | ||
| 73 | |||
| 74 | impl TestHome { | ||
| 75 | pub fn new() -> Self { | ||
| 76 | let root = TempDir::new().unwrap(); | ||
| 77 | let home_dir = root.path().join("home"); | ||
| 78 | let xdg_config_home = root.path().join("xdg-config"); | ||
| 79 | std::fs::create_dir_all(&home_dir).unwrap(); | ||
| 80 | std::fs::create_dir_all(&xdg_config_home).unwrap(); | ||
| 81 | |||
| 82 | let git_config_global = root.path().join("gitconfig"); | ||
| 83 | std::fs::write(&git_config_global, "").unwrap(); | ||
| 84 | |||
| 85 | Self { | ||
| 86 | root, | ||
| 87 | home_dir, | ||
| 88 | xdg_config_home, | ||
| 89 | git_config_global, | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn home_dir(&self) -> &Path { | ||
| 94 | &self.home_dir | ||
| 95 | } | ||
| 96 | |||
| 97 | pub fn config_home(&self) -> &Path { | ||
| 98 | &self.xdg_config_home | ||
| 99 | } | ||
| 100 | |||
| 101 | pub fn collab_config_dir(&self) -> PathBuf { | ||
| 102 | self.xdg_config_home.join("git-collab") | ||
| 103 | } | ||
| 104 | |||
| 105 | pub fn ensure_signing_key(&self) { | ||
| 106 | let config_dir = self.collab_config_dir(); | ||
| 107 | if !config_dir.join("signing-key").exists() { | ||
| 108 | setup_signing_key(&config_dir); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn apply_to_command<'a>(&self, cmd: &'a mut Command) -> &'a mut Command { | ||
| 113 | let _ = &self.root; | ||
| 114 | cmd.env("HOME", &self.home_dir) | ||
| 115 | .env("XDG_CONFIG_HOME", &self.xdg_config_home) | ||
| 116 | .env("GIT_CONFIG_GLOBAL", &self.git_config_global) | ||
| 117 | .env("GIT_CONFIG_NOSYSTEM", "1") | ||
| 118 | } | ||
| 119 | |||
| 120 | fn apply_to_process(&self) { | ||
| 121 | let _ = &self.root; | ||
| 122 | env::set_var("HOME", &self.home_dir); | ||
| 123 | env::set_var("XDG_CONFIG_HOME", &self.xdg_config_home); | ||
| 124 | env::set_var("GIT_CONFIG_GLOBAL", &self.git_config_global); | ||
| 125 | env::set_var("GIT_CONFIG_NOSYSTEM", "1"); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | pub struct ScopedTestConfig { | ||
| 130 | _lock: MutexGuard<'static, ()>, | ||
| 131 | env: TestHome, | ||
| 132 | old_home: Option<OsString>, | ||
| 133 | old_xdg_config_home: Option<OsString>, | ||
| 134 | old_git_config_global: Option<OsString>, | ||
| 135 | old_git_config_nosystem: Option<OsString>, | ||
| 136 | } | ||
| 137 | |||
| 138 | impl ScopedTestConfig { | ||
| 139 | pub fn new() -> Self { | ||
| 140 | let lock = test_env_lock().lock().unwrap(); | ||
| 141 | let test_home = TestHome::new(); | ||
| 142 | let old_home = env::var_os("HOME"); | ||
| 143 | let old_xdg_config_home = env::var_os("XDG_CONFIG_HOME"); | ||
| 144 | let old_git_config_global = env::var_os("GIT_CONFIG_GLOBAL"); | ||
| 145 | let old_git_config_nosystem = env::var_os("GIT_CONFIG_NOSYSTEM"); | ||
| 146 | test_home.apply_to_process(); | ||
| 147 | |||
| 148 | Self { | ||
| 149 | _lock: lock, | ||
| 150 | env: test_home, | ||
| 151 | old_home, | ||
| 152 | old_xdg_config_home, | ||
| 153 | old_git_config_global, | ||
| 154 | old_git_config_nosystem, | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | pub fn config_dir(&self) -> PathBuf { | ||
| 159 | self.env.collab_config_dir() | ||
| 160 | } | ||
| 161 | |||
| 162 | pub fn ensure_signing_key(&self) { | ||
| 163 | self.env.ensure_signing_key(); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | impl Drop for ScopedTestConfig { | ||
| 168 | fn drop(&mut self) { | ||
| 169 | set_or_remove_env("HOME", self.old_home.take()); | ||
| 170 | set_or_remove_env("XDG_CONFIG_HOME", self.old_xdg_config_home.take()); | ||
| 171 | set_or_remove_env("GIT_CONFIG_GLOBAL", self.old_git_config_global.take()); | ||
| 172 | set_or_remove_env("GIT_CONFIG_NOSYSTEM", self.old_git_config_nosystem.take()); | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 48 | /// Create a non-bare repo in a directory with user identity configured | 176 | /// Create a non-bare repo in a directory with user identity configured |
| 49 | /// and an initial empty commit on `main`. | 177 | /// and an initial empty commit on `main`. |
| 50 | pub fn init_repo(dir: &Path, author: &Author) -> Repository { | 178 | pub fn init_repo(dir: &Path, author: &Author) -> Repository { |
| @@ -175,37 +303,47 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R | |||
| 175 | /// A temporary git repository for end-to-end CLI testing. | 303 | /// A temporary git repository for end-to-end CLI testing. |
| 176 | pub struct TestRepo { | 304 | pub struct TestRepo { |
| 177 | pub dir: TempDir, | 305 | pub dir: TempDir, |
| 306 | env: TestHome, | ||
| 178 | } | 307 | } |
| 179 | 308 | ||
| 180 | impl TestRepo { | 309 | impl TestRepo { |
| 181 | /// Create a new repo with user identity and an initial empty commit on `main`. | 310 | /// Create a new repo with user identity and an initial empty commit on `main`. |
| 182 | /// Also ensures a signing key exists in the default config dir. | 311 | /// Also ensures a signing key exists in an isolated temp config dir. |
| 183 | pub fn new(name: &str, email: &str) -> Self { | 312 | pub fn new(name: &str, email: &str) -> Self { |
| 184 | let dir = TempDir::new().unwrap(); | 313 | let dir = TempDir::new().unwrap(); |
| 185 | git(dir.path(), &["init", "-b", "main"]); | 314 | let env = TestHome::new(); |
| 186 | git(dir.path(), &["config", "user.name", name]); | 315 | git_with_env(dir.path(), &["init", "-b", "main"], &env); |
| 187 | git(dir.path(), &["config", "user.email", email]); | 316 | git_with_env(dir.path(), &["config", "user.name", name], &env); |
| 188 | git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]); | 317 | git_with_env(dir.path(), &["config", "user.email", email], &env); |
| 189 | 318 | git_with_env(dir.path(), &["commit", "--allow-empty", "-m", "initial"], &env); | |
| 190 | // Ensure signing key exists for CLI operations | 319 | env.ensure_signing_key(); |
| 191 | let config_dir = dirs::config_dir() | 320 | |
| 192 | .unwrap_or_else(|| { | 321 | TestRepo { dir, env } |
| 193 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | 322 | } |
| 194 | std::path::PathBuf::from(home).join(".config") | ||
| 195 | }) | ||
| 196 | .join("git-collab"); | ||
| 197 | if !config_dir.join("signing-key").exists() { | ||
| 198 | setup_signing_key(&config_dir); | ||
| 199 | } | ||
| 200 | 323 | ||
| 201 | TestRepo { dir } | 324 | pub fn home_dir(&self) -> &Path { |
| 325 | self.env.home_dir() | ||
| 326 | } | ||
| 327 | |||
| 328 | pub fn config_dir(&self) -> PathBuf { | ||
| 329 | self.env.collab_config_dir() | ||
| 330 | } | ||
| 331 | |||
| 332 | pub fn apply_env<'a>(&self, cmd: &'a mut Command) -> &'a mut Command { | ||
| 333 | self.env.apply_to_command(cmd) | ||
| 334 | } | ||
| 335 | |||
| 336 | pub fn cli_command(&self) -> Command { | ||
| 337 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_git-collab")); | ||
| 338 | self.apply_env(&mut cmd); | ||
| 339 | cmd.current_dir(self.dir.path()); | ||
| 340 | cmd | ||
| 202 | } | 341 | } |
| 203 | 342 | ||
| 204 | /// Run git-collab and return raw output. | 343 | /// Run git-collab and return raw output. |
| 205 | pub fn run(&self, args: &[&str]) -> Output { | 344 | pub fn run(&self, args: &[&str]) -> Output { |
| 206 | Command::new(env!("CARGO_BIN_EXE_git-collab")) | 345 | self.cli_command() |
| 207 | .args(args) | 346 | .args(args) |
| 208 | .current_dir(self.dir.path()) | ||
| 209 | .output() | 347 | .output() |
| 210 | .expect("failed to run git-collab") | 348 | .expect("failed to run git-collab") |
| 211 | } | 349 | } |
| @@ -238,6 +376,40 @@ impl TestRepo { | |||
| 238 | String::from_utf8(output.stderr).unwrap() | 376 | String::from_utf8(output.stderr).unwrap() |
| 239 | } | 377 | } |
| 240 | 378 | ||
| 379 | /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output. | ||
| 380 | pub fn run_dashboard_smoke(&self, input: &str) -> Output { | ||
| 381 | let mut command = Command::new("script"); | ||
| 382 | self.apply_env(&mut command); | ||
| 383 | let mut child = command | ||
| 384 | .args([ | ||
| 385 | "-qec", | ||
| 386 | &format!("{} dashboard", env!("CARGO_BIN_EXE_git-collab")), | ||
| 387 | "/dev/null", | ||
| 388 | ]) | ||
| 389 | .current_dir(self.dir.path()) | ||
| 390 | .stdin(Stdio::piped()) | ||
| 391 | .stdout(Stdio::piped()) | ||
| 392 | .stderr(Stdio::piped()) | ||
| 393 | .spawn() | ||
| 394 | .expect("failed to launch dashboard in pty"); | ||
| 395 | |||
| 396 | if let Some(mut stdin) = child.stdin.take() { | ||
| 397 | stdin.write_all(input.as_bytes()).unwrap(); | ||
| 398 | } | ||
| 399 | |||
| 400 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 401 | loop { | ||
| 402 | if let Some(_status) = child.try_wait().expect("failed to poll dashboard process") { | ||
| 403 | return child.wait_with_output().expect("failed to collect dashboard output"); | ||
| 404 | } | ||
| 405 | if Instant::now() >= deadline { | ||
| 406 | let _ = child.kill(); | ||
| 407 | return child.wait_with_output().expect("failed to collect timed out dashboard output"); | ||
| 408 | } | ||
| 409 | thread::sleep(Duration::from_millis(20)); | ||
| 410 | } | ||
| 411 | } | ||
| 412 | |||
| 241 | /// Open an issue and return the 8-char short ID. | 413 | /// Open an issue and return the 8-char short ID. |
| 242 | pub fn issue_open(&self, title: &str) -> String { | 414 | pub fn issue_open(&self, title: &str) -> String { |
| 243 | let out = self.run_ok(&["issue", "open", "-t", title]); | 415 | let out = self.run_ok(&["issue", "open", "-t", title]); |
| @@ -269,7 +441,9 @@ impl TestRepo { | |||
| 269 | 441 | ||
| 270 | /// Run a git command in this repo and return stdout. | 442 | /// Run a git command in this repo and return stdout. |
| 271 | pub fn git(&self, args: &[&str]) -> String { | 443 | pub fn git(&self, args: &[&str]) -> String { |
| 272 | let output = Command::new("git") | 444 | let mut command = Command::new("git"); |
| 445 | self.apply_env(&mut command); | ||
| 446 | let output = command | ||
| 273 | .args(args) | 447 | .args(args) |
| 274 | .current_dir(self.dir.path()) | 448 | .current_dir(self.dir.path()) |
| 275 | .output() | 449 | .output() |
| @@ -296,6 +470,158 @@ impl TestRepo { | |||
| 296 | } | 470 | } |
| 297 | } | 471 | } |
| 298 | 472 | ||
| 473 | pub struct HttpResponse { | ||
| 474 | pub status_line: String, | ||
| 475 | pub body: String, | ||
| 476 | } | ||
| 477 | |||
| 478 | pub struct ServerHarness { | ||
| 479 | root: TempDir, | ||
| 480 | repo_name: String, | ||
| 481 | work_repo: TestRepo, | ||
| 482 | server: Child, | ||
| 483 | http_addr: SocketAddr, | ||
| 484 | } | ||
| 485 | |||
| 486 | impl ServerHarness { | ||
| 487 | pub fn new(repo_name: &str) -> Self { | ||
| 488 | let root = TempDir::new().unwrap(); | ||
| 489 | let repos_dir = root.path().join("repos"); | ||
| 490 | std::fs::create_dir_all(&repos_dir).unwrap(); | ||
| 491 | |||
| 492 | let bare_repo_dir = repos_dir.join(format!("{repo_name}.git")); | ||
| 493 | git_cmd(root.path(), &["init", "--bare", bare_repo_dir.to_str().unwrap()]); | ||
| 494 | |||
| 495 | let work_repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 496 | work_repo.git(&["remote", "add", "origin", bare_repo_dir.to_str().unwrap()]); | ||
| 497 | |||
| 498 | let authorized_keys = root.path().join("authorized_keys"); | ||
| 499 | std::fs::write(&authorized_keys, "").unwrap(); | ||
| 500 | |||
| 501 | let http_addr = pick_loopback_addr(); | ||
| 502 | let ssh_addr = pick_loopback_addr(); | ||
| 503 | let config_path = root.path().join("server.toml"); | ||
| 504 | std::fs::write( | ||
| 505 | &config_path, | ||
| 506 | format!( | ||
| 507 | "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n", | ||
| 508 | repos_dir, | ||
| 509 | http_addr, | ||
| 510 | ssh_addr, | ||
| 511 | authorized_keys, | ||
| 512 | ), | ||
| 513 | ) | ||
| 514 | .unwrap(); | ||
| 515 | |||
| 516 | let server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | ||
| 517 | .args(["--config", config_path.to_str().unwrap()]) | ||
| 518 | .stdout(Stdio::piped()) | ||
| 519 | .stderr(Stdio::piped()) | ||
| 520 | .spawn() | ||
| 521 | .expect("failed to start git-collab-server"); | ||
| 522 | |||
| 523 | let mut harness = Self { | ||
| 524 | root, | ||
| 525 | repo_name: repo_name.to_string(), | ||
| 526 | work_repo, | ||
| 527 | server, | ||
| 528 | http_addr, | ||
| 529 | }; | ||
| 530 | harness.wait_until_ready(); | ||
| 531 | harness | ||
| 532 | } | ||
| 533 | |||
| 534 | pub fn repo_name(&self) -> &str { | ||
| 535 | &self.repo_name | ||
| 536 | } | ||
| 537 | |||
| 538 | pub fn work_repo(&self) -> &TestRepo { | ||
| 539 | &self.work_repo | ||
| 540 | } | ||
| 541 | |||
| 542 | pub fn push_head(&self) { | ||
| 543 | self.work_repo.git(&["push", "-u", "origin", "main"]); | ||
| 544 | } | ||
| 545 | |||
| 546 | pub fn push_collab_refs(&self) { | ||
| 547 | self.work_repo | ||
| 548 | .git(&["push", "origin", "refs/collab/*:refs/collab/*"]); | ||
| 549 | } | ||
| 550 | |||
| 551 | pub fn get_ok(&self, path: &str) -> HttpResponse { | ||
| 552 | let response = self.get(path); | ||
| 553 | assert!( | ||
| 554 | response.status_line.contains("200"), | ||
| 555 | "expected 200 for {path}, got {}\nbody:\n{}", | ||
| 556 | response.status_line, | ||
| 557 | response.body | ||
| 558 | ); | ||
| 559 | response | ||
| 560 | } | ||
| 561 | |||
| 562 | pub fn get(&self, path: &str) -> HttpResponse { | ||
| 563 | let mut stream = TcpStream::connect(self.http_addr) | ||
| 564 | .unwrap_or_else(|e| panic!("failed to connect to http server on {}: {}", self.http_addr, e)); | ||
| 565 | stream | ||
| 566 | .write_all( | ||
| 567 | format!( | ||
| 568 | "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", | ||
| 569 | path, self.http_addr | ||
| 570 | ) | ||
| 571 | .as_bytes(), | ||
| 572 | ) | ||
| 573 | .unwrap(); | ||
| 574 | let mut raw = Vec::new(); | ||
| 575 | stream.read_to_end(&mut raw).unwrap(); | ||
| 576 | let raw = String::from_utf8(raw).unwrap(); | ||
| 577 | let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, "")); | ||
| 578 | let status_line = head.lines().next().unwrap_or("").to_string(); | ||
| 579 | HttpResponse { | ||
| 580 | status_line, | ||
| 581 | body: body.to_string(), | ||
| 582 | } | ||
| 583 | } | ||
| 584 | |||
| 585 | fn wait_until_ready(&mut self) { | ||
| 586 | let deadline = Instant::now() + Duration::from_secs(10); | ||
| 587 | loop { | ||
| 588 | if let Some(status) = self.server_status() { | ||
| 589 | panic!("git-collab-server exited before becoming ready: {status}"); | ||
| 590 | } | ||
| 591 | |||
| 592 | if let Ok(response) = TcpStream::connect(self.http_addr) { | ||
| 593 | drop(response); | ||
| 594 | let response = self.get("/"); | ||
| 595 | if !response.status_line.is_empty() { | ||
| 596 | return; | ||
| 597 | } | ||
| 598 | } | ||
| 599 | |||
| 600 | if Instant::now() >= deadline { | ||
| 601 | panic!("timed out waiting for git-collab-server on {}", self.http_addr); | ||
| 602 | } | ||
| 603 | |||
| 604 | thread::sleep(Duration::from_millis(50)); | ||
| 605 | } | ||
| 606 | } | ||
| 607 | |||
| 608 | fn server_status(&mut self) -> Option<String> { | ||
| 609 | self.server | ||
| 610 | .try_wait() | ||
| 611 | .ok() | ||
| 612 | .flatten() | ||
| 613 | .map(|status| format!("exit status {:?}", status.code())) | ||
| 614 | } | ||
| 615 | } | ||
| 616 | |||
| 617 | impl Drop for ServerHarness { | ||
| 618 | fn drop(&mut self) { | ||
| 619 | let _ = self.server.kill(); | ||
| 620 | let _ = self.server.wait(); | ||
| 621 | let _ = &self.root; | ||
| 622 | } | ||
| 623 | } | ||
| 624 | |||
| 299 | /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs). | 625 | /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs). |
| 300 | /// Returns the commit OID. | 626 | /// Returns the commit OID. |
| 301 | pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { | 627 | pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { |
| @@ -350,6 +676,10 @@ pub fn git_cmd(dir: &Path, args: &[&str]) { | |||
| 350 | git(dir, args); | 676 | git(dir, args); |
| 351 | } | 677 | } |
| 352 | 678 | ||
| 679 | pub fn git_cmd_with_env(dir: &Path, args: &[&str], env: &TestHome) { | ||
| 680 | git_with_env(dir, args, env); | ||
| 681 | } | ||
| 682 | |||
| 353 | fn git(dir: &Path, args: &[&str]) { | 683 | fn git(dir: &Path, args: &[&str]) { |
| 354 | let output = Command::new("git") | 684 | let output = Command::new("git") |
| 355 | .args(args) | 685 | .args(args) |
| @@ -363,3 +693,26 @@ fn git(dir: &Path, args: &[&str]) { | |||
| 363 | String::from_utf8_lossy(&output.stderr) | 693 | String::from_utf8_lossy(&output.stderr) |
| 364 | ); | 694 | ); |
| 365 | } | 695 | } |
| 696 | |||
| 697 | fn git_with_env(dir: &Path, args: &[&str], test_home: &TestHome) { | ||
| 698 | let mut command = Command::new("git"); | ||
| 699 | test_home.apply_to_command(&mut command); | ||
| 700 | let output = command | ||
| 701 | .args(args) | ||
| 702 | .current_dir(dir) | ||
| 703 | .output() | ||
| 704 | .expect("failed to run git"); | ||
| 705 | assert!( | ||
| 706 | output.status.success(), | ||
| 707 | "git {:?} failed: {}", | ||
| 708 | args, | ||
| 709 | String::from_utf8_lossy(&output.stderr) | ||
| 710 | ); | ||
| 711 | } | ||
| 712 | |||
| 713 | fn pick_loopback_addr() -> SocketAddr { | ||
| 714 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); | ||
| 715 | let addr = listener.local_addr().unwrap(); | ||
| 716 | drop(listener); | ||
| 717 | addr | ||
| 718 | } | ||
tests/server_behavior_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,102 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::ServerHarness; | ||
| 4 | |||
| 5 | #[test] | ||
| 6 | fn issue_created_locally_and_pushed_renders_in_server_ui() { | ||
| 7 | let harness = ServerHarness::new("behavior-issues"); | ||
| 8 | |||
| 9 | harness.push_head(); | ||
| 10 | |||
| 11 | let issue_id = harness.work_repo().issue_open("My first bug"); | ||
| 12 | harness.push_collab_refs(); | ||
| 13 | |||
| 14 | let list_page = harness.get_ok(&format!("/{}/issues", harness.repo_name())); | ||
| 15 | assert!(list_page.body.contains("My first bug")); | ||
| 16 | assert!(list_page.body.contains("open")); | ||
| 17 | |||
| 18 | let detail_page = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id)); | ||
| 19 | assert!(detail_page.body.contains("My first bug")); | ||
| 20 | assert!(detail_page.body.contains("Alice")); | ||
| 21 | assert!(detail_page.body.contains("open")); | ||
| 22 | } | ||
| 23 | |||
| 24 | #[test] | ||
| 25 | fn pushed_repository_content_renders_across_repo_http_pages() { | ||
| 26 | let harness = ServerHarness::new("behavior-pages"); | ||
| 27 | |||
| 28 | let commit_oid = harness.work_repo().commit_file( | ||
| 29 | "src/lib.rs", | ||
| 30 | "pub fn answer() -> u32 {\n 42\n}\n", | ||
| 31 | "add source file", | ||
| 32 | ); | ||
| 33 | let issue_id = harness.work_repo().issue_open("Server issue"); | ||
| 34 | let patch_id = harness.work_repo().patch_create("Server patch"); | ||
| 35 | |||
| 36 | harness.push_head(); | ||
| 37 | harness.push_collab_refs(); | ||
| 38 | |||
| 39 | let repo_list = harness.get_ok("/"); | ||
| 40 | assert!(repo_list.body.contains(harness.repo_name())); | ||
| 41 | |||
| 42 | let overview = harness.get_ok(&format!("/{}", harness.repo_name())); | ||
| 43 | assert!(overview.body.contains("Server issue")); | ||
| 44 | assert!(overview.body.contains("Server patch")); | ||
| 45 | assert!(overview.body.contains("add source file")); | ||
| 46 | |||
| 47 | let commits = harness.get_ok(&format!("/{}/commits", harness.repo_name())); | ||
| 48 | assert!(commits.body.contains("add source file")); | ||
| 49 | assert!(commits.body.contains("main")); | ||
| 50 | |||
| 51 | let diff = harness.get_ok(&format!("/{}/diff/{}", harness.repo_name(), commit_oid)); | ||
| 52 | assert!(diff.body.contains("add source file")); | ||
| 53 | assert!(diff.body.contains("src/lib.rs")); | ||
| 54 | assert!(diff.body.contains("answer")); | ||
| 55 | |||
| 56 | let tree = harness.get_ok(&format!("/{}/tree", harness.repo_name())); | ||
| 57 | assert!(tree.body.contains("src/")); | ||
| 58 | |||
| 59 | let blob = harness.get_ok(&format!("/{}/blob/main/src/lib.rs", harness.repo_name())); | ||
| 60 | assert!(blob.body.contains("src/lib.rs")); | ||
| 61 | assert!(blob.body.contains("answer")); | ||
| 62 | |||
| 63 | let patches = harness.get_ok(&format!("/{}/patches", harness.repo_name())); | ||
| 64 | assert!(patches.body.contains("Server patch")); | ||
| 65 | assert!(patches.body.contains("open")); | ||
| 66 | |||
| 67 | let patch_detail = harness.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id)); | ||
| 68 | assert!(patch_detail.body.contains("Server patch")); | ||
| 69 | assert!(patch_detail.body.contains("Alice")); | ||
| 70 | assert!(patch_detail.body.contains("main")); | ||
| 71 | |||
| 72 | let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name())); | ||
| 73 | assert!(issues.body.contains("Server issue")); | ||
| 74 | assert!(issues.body.contains("open")); | ||
| 75 | |||
| 76 | let issue_detail = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id)); | ||
| 77 | assert!(issue_detail.body.contains("Server issue")); | ||
| 78 | assert!(issue_detail.body.contains("Alice")); | ||
| 79 | assert!(issue_detail.body.contains("open")); | ||
| 80 | } | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn missing_repository_and_missing_objects_return_not_found() { | ||
| 84 | let harness = ServerHarness::new("behavior-not-found"); | ||
| 85 | |||
| 86 | harness.push_head(); | ||
| 87 | |||
| 88 | let missing_repo = harness.get("/missing-repo"); | ||
| 89 | assert!(missing_repo.status_line.contains("404")); | ||
| 90 | assert!(missing_repo.body.contains("missing-repo")); | ||
| 91 | assert!(missing_repo.body.contains("not found")); | ||
| 92 | |||
| 93 | let missing_issue = harness.get(&format!("/{}/issues/missing-issue", harness.repo_name())); | ||
| 94 | assert!(missing_issue.status_line.contains("404")); | ||
| 95 | assert!(missing_issue.body.contains("missing-issue")); | ||
| 96 | assert!(missing_issue.body.contains("not found")); | ||
| 97 | |||
| 98 | let missing_blob = harness.get(&format!("/{}/blob/main/src/missing.rs", harness.repo_name())); | ||
| 99 | assert!(missing_blob.status_line.contains("404")); | ||
| 100 | assert!(missing_blob.body.contains("src/missing.rs")); | ||
| 101 | assert!(missing_blob.body.contains("not found")); | ||
| 102 | } | ||
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -7,11 +7,12 @@ | |||
| 7 | 7 | ||
| 8 | mod common; | 8 | mod common; |
| 9 | 9 | ||
| 10 | use std::process::{Command, Output}; | ||
| 11 | |||
| 10 | use tempfile::TempDir; | 12 | use tempfile::TempDir; |
| 11 | 13 | ||
| 12 | use git2::Repository; | 14 | use git2::Repository; |
| 13 | use git_collab::dag; | 15 | use git_collab::dag; |
| 14 | use git_collab::error; | ||
| 15 | use git_collab::event::{Action, Event, ReviewVerdict}; | 16 | use git_collab::event::{Action, Event, ReviewVerdict}; |
| 16 | use git_collab::signing; | 17 | use git_collab::signing; |
| 17 | use git_collab::state::{self, IssueState, IssueStatus, PatchState}; | 18 | use git_collab::state::{self, IssueState, IssueStatus, PatchState}; |
| @@ -19,7 +20,7 @@ use git_collab::sync; | |||
| 19 | 20 | ||
| 20 | use common::{ | 21 | use common::{ |
| 21 | add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now, | 22 | add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now, |
| 22 | open_issue, setup_signing_key, test_signing_key, | 23 | open_issue, test_signing_key, ScopedTestConfig, |
| 23 | }; | 24 | }; |
| 24 | 25 | ||
| 25 | // --------------------------------------------------------------------------- | 26 | // --------------------------------------------------------------------------- |
| @@ -30,11 +31,21 @@ struct TestCluster { | |||
| 30 | bare_dir: TempDir, | 31 | bare_dir: TempDir, |
| 31 | alice_dir: TempDir, | 32 | alice_dir: TempDir, |
| 32 | bob_dir: TempDir, | 33 | bob_dir: TempDir, |
| 33 | _key_setup: (), // signing key created in default config dir | 34 | _config: ScopedTestConfig, |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | impl TestCluster { | 37 | impl TestCluster { |
| 37 | fn new() -> Self { | 38 | fn new() -> Self { |
| 39 | let cluster = Self::new_without_collab_init(); | ||
| 40 | sync::init(&cluster.alice_repo()).unwrap(); | ||
| 41 | sync::init(&cluster.bob_repo()).unwrap(); | ||
| 42 | cluster | ||
| 43 | } | ||
| 44 | |||
| 45 | fn new_without_collab_init() -> Self { | ||
| 46 | let config = ScopedTestConfig::new(); | ||
| 47 | config.ensure_signing_key(); | ||
| 48 | |||
| 38 | let bare_dir = TempDir::new().unwrap(); | 49 | let bare_dir = TempDir::new().unwrap(); |
| 39 | let bare_repo = Repository::init_bare(bare_dir.path()).unwrap(); | 50 | let bare_repo = Repository::init_bare(bare_dir.path()).unwrap(); |
| 40 | 51 | ||
| @@ -58,7 +69,6 @@ impl TestCluster { | |||
| 58 | config.set_str("user.name", "Alice").unwrap(); | 69 | config.set_str("user.name", "Alice").unwrap(); |
| 59 | config.set_str("user.email", "alice@example.com").unwrap(); | 70 | config.set_str("user.email", "alice@example.com").unwrap(); |
| 60 | } | 71 | } |
| 61 | sync::init(&alice_repo).unwrap(); | ||
| 62 | 72 | ||
| 63 | let bob_repo = | 73 | let bob_repo = |
| 64 | Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap(); | 74 | Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap(); |
| @@ -67,24 +77,12 @@ impl TestCluster { | |||
| 67 | config.set_str("user.name", "Bob").unwrap(); | 77 | config.set_str("user.name", "Bob").unwrap(); |
| 68 | config.set_str("user.email", "bob@example.com").unwrap(); | 78 | config.set_str("user.email", "bob@example.com").unwrap(); |
| 69 | } | 79 | } |
| 70 | sync::init(&bob_repo).unwrap(); | ||
| 71 | |||
| 72 | // Ensure signing key exists for sync reconciliation | ||
| 73 | let config_dir = dirs::config_dir() | ||
| 74 | .unwrap_or_else(|| { | ||
| 75 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | ||
| 76 | std::path::PathBuf::from(home).join(".config") | ||
| 77 | }) | ||
| 78 | .join("git-collab"); | ||
| 79 | if !config_dir.join("signing-key").exists() { | ||
| 80 | setup_signing_key(&config_dir); | ||
| 81 | } | ||
| 82 | 80 | ||
| 83 | TestCluster { | 81 | TestCluster { |
| 84 | bare_dir, | 82 | bare_dir, |
| 85 | alice_dir, | 83 | alice_dir, |
| 86 | bob_dir, | 84 | bob_dir, |
| 87 | _key_setup: (), | 85 | _config: config, |
| 88 | } | 86 | } |
| 89 | } | 87 | } |
| 90 | 88 | ||
| @@ -96,10 +94,51 @@ impl TestCluster { | |||
| 96 | Repository::open(self.bob_dir.path()).unwrap() | 94 | Repository::open(self.bob_dir.path()).unwrap() |
| 97 | } | 95 | } |
| 98 | 96 | ||
| 97 | fn run_collab(&self, repo_dir: &std::path::Path, args: &[&str]) -> Output { | ||
| 98 | Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 99 | .args(args) | ||
| 100 | .current_dir(repo_dir) | ||
| 101 | .output() | ||
| 102 | .expect("failed to run git-collab") | ||
| 103 | } | ||
| 104 | |||
| 105 | fn run_collab_ok(&self, repo_dir: &std::path::Path, args: &[&str]) -> String { | ||
| 106 | let output = self.run_collab(repo_dir, args); | ||
| 107 | let stdout = String::from_utf8(output.stdout).unwrap(); | ||
| 108 | let stderr = String::from_utf8(output.stderr).unwrap(); | ||
| 109 | assert!( | ||
| 110 | output.status.success(), | ||
| 111 | "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}", | ||
| 112 | args, | ||
| 113 | output.status.code(), | ||
| 114 | stdout, | ||
| 115 | stderr | ||
| 116 | ); | ||
| 117 | stdout | ||
| 118 | } | ||
| 119 | |||
| 120 | fn run_collab_err(&self, repo_dir: &std::path::Path, args: &[&str]) -> (String, String) { | ||
| 121 | let output = self.run_collab(repo_dir, args); | ||
| 122 | let stdout = String::from_utf8(output.stdout).unwrap(); | ||
| 123 | let stderr = String::from_utf8(output.stderr).unwrap(); | ||
| 124 | assert!( | ||
| 125 | !output.status.success(), | ||
| 126 | "expected git-collab {:?} to fail but it succeeded:\nstdout: {}\nstderr: {}", | ||
| 127 | args, | ||
| 128 | stdout, | ||
| 129 | stderr | ||
| 130 | ); | ||
| 131 | (stdout, stderr) | ||
| 132 | } | ||
| 133 | |||
| 99 | /// Return the path to the bare remote directory. | 134 | /// Return the path to the bare remote directory. |
| 100 | fn bare_dir(&self) -> &std::path::Path { | 135 | fn bare_dir(&self) -> &std::path::Path { |
| 101 | self.bare_dir.path() | 136 | self.bare_dir.path() |
| 102 | } | 137 | } |
| 138 | |||
| 139 | fn config_dir(&self) -> std::path::PathBuf { | ||
| 140 | self._config.config_dir() | ||
| 141 | } | ||
| 103 | } | 142 | } |
| 104 | 143 | ||
| 105 | // --------------------------------------------------------------------------- | 144 | // --------------------------------------------------------------------------- |
| @@ -125,6 +164,94 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() { | |||
| 125 | } | 164 | } |
| 126 | 165 | ||
| 127 | #[test] | 166 | #[test] |
| 167 | fn test_cli_init_and_sync_transfer_issue_between_repos() { | ||
| 168 | let cluster = TestCluster::new_without_collab_init(); | ||
| 169 | |||
| 170 | let alice_init = cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); | ||
| 171 | assert!(alice_init.contains("Configured remote 'origin'")); | ||
| 172 | assert!(alice_init.contains("Collab refspecs initialized.")); | ||
| 173 | |||
| 174 | let bob_init = cluster.run_collab_ok(cluster.bob_dir.path(), &["init"]); | ||
| 175 | assert!(bob_init.contains("Configured remote 'origin'")); | ||
| 176 | assert!(bob_init.contains("Collab refspecs initialized.")); | ||
| 177 | |||
| 178 | let issue_open = cluster.run_collab_ok( | ||
| 179 | cluster.alice_dir.path(), | ||
| 180 | &["issue", "open", "-t", "CLI sync issue"], | ||
| 181 | ); | ||
| 182 | assert!(issue_open.contains("Opened issue")); | ||
| 183 | |||
| 184 | let issue_id = state::list_issues(&cluster.alice_repo()) | ||
| 185 | .unwrap() | ||
| 186 | .into_iter() | ||
| 187 | .find(|issue| issue.title == "CLI sync issue") | ||
| 188 | .expect("issue created through CLI should exist locally") | ||
| 189 | .id; | ||
| 190 | |||
| 191 | let alice_sync = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); | ||
| 192 | assert!(alice_sync.contains("Fetching from 'origin'...")); | ||
| 193 | assert!(alice_sync.contains("Pushing to 'origin'...")); | ||
| 194 | assert!(alice_sync.contains("Sync complete.")); | ||
| 195 | |||
| 196 | let bob_sync = cluster.run_collab_ok(cluster.bob_dir.path(), &["sync", "origin"]); | ||
| 197 | assert!(bob_sync.contains("Fetching from 'origin'...")); | ||
| 198 | assert!(bob_sync.contains("Sync complete.")); | ||
| 199 | |||
| 200 | let bob_repo = cluster.bob_repo(); | ||
| 201 | let bob_ref = format!("refs/collab/issues/{}", issue_id); | ||
| 202 | let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &issue_id).unwrap(); | ||
| 203 | assert_eq!(bob_state.title, "CLI sync issue"); | ||
| 204 | assert_eq!(bob_state.author.name, "Alice"); | ||
| 205 | } | ||
| 206 | |||
| 207 | #[test] | ||
| 208 | fn test_cli_sync_reports_missing_remote_failure() { | ||
| 209 | let cluster = TestCluster::new_without_collab_init(); | ||
| 210 | |||
| 211 | cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); | ||
| 212 | |||
| 213 | let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "upstream"]); | ||
| 214 | assert!(stderr.contains("error:")); | ||
| 215 | assert!(stderr.contains("git fetch exited with status")); | ||
| 216 | } | ||
| 217 | |||
| 218 | #[test] | ||
| 219 | fn test_cli_sync_partial_failure_can_resume_successfully() { | ||
| 220 | let cluster = TestCluster::new_without_collab_init(); | ||
| 221 | |||
| 222 | cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); | ||
| 223 | |||
| 224 | let alice_repo = cluster.alice_repo(); | ||
| 225 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "CLI rejected issue"); | ||
| 226 | let (_ref2, id2) = open_issue(&alice_repo, &alice(), "CLI accepted issue"); | ||
| 227 | |||
| 228 | install_reject_hook(cluster.bare_dir(), &id1[..8]); | ||
| 229 | |||
| 230 | let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "origin"]); | ||
| 231 | assert!(stderr.contains("Sync partially failed: 1 of 2 refs pushed.")); | ||
| 232 | assert!(stderr.contains(&format!("refs/collab/issues/{}", id1))); | ||
| 233 | |||
| 234 | let state = sync::SyncState::load(&cluster.alice_repo()).expect("partial sync should save state"); | ||
| 235 | assert_eq!(state.pending_refs.len(), 1); | ||
| 236 | assert!(state.pending_refs[0].0.contains(&id1)); | ||
| 237 | |||
| 238 | let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap(); | ||
| 239 | assert!( | ||
| 240 | bare_repo | ||
| 241 | .refname_to_id(&format!("refs/collab/issues/{}", id2)) | ||
| 242 | .is_ok(), | ||
| 243 | "successful ref should still reach the remote" | ||
| 244 | ); | ||
| 245 | |||
| 246 | remove_reject_hook(cluster.bare_dir()); | ||
| 247 | |||
| 248 | let resume_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); | ||
| 249 | assert!(resume_output.contains("Resuming sync to 'origin'")); | ||
| 250 | assert!(resume_output.contains("Sync complete.")); | ||
| 251 | assert!(sync::SyncState::load(&cluster.alice_repo()).is_none()); | ||
| 252 | } | ||
| 253 | |||
| 254 | #[test] | ||
| 128 | fn test_bob_comments_on_alice_issue_then_sync() { | 255 | fn test_bob_comments_on_alice_issue_then_sync() { |
| 129 | let cluster = TestCluster::new(); | 256 | let cluster = TestCluster::new(); |
| 130 | let alice_repo = cluster.alice_repo(); | 257 | let alice_repo = cluster.alice_repo(); |
| @@ -599,14 +726,8 @@ fn test_reconciliation_merge_commit_is_signed() { | |||
| 599 | } | 726 | } |
| 600 | 727 | ||
| 601 | // Verify the merge commit specifically is signed by the syncing user's key | 728 | // Verify the merge commit specifically is signed by the syncing user's key |
| 602 | // (the key stored in the config dir, which sync::sync() loads) | 729 | // (the key stored in the test config dir, which sync::sync() loads) |
| 603 | let config_dir = dirs::config_dir() | 730 | let syncing_vk = signing::load_verifying_key(&cluster.config_dir()).unwrap(); |
| 604 | .unwrap_or_else(|| { | ||
| 605 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | ||
| 606 | std::path::PathBuf::from(home).join(".config") | ||
| 607 | }) | ||
| 608 | .join("git-collab"); | ||
| 609 | let syncing_vk = signing::load_verifying_key(&config_dir).unwrap(); | ||
| 610 | let syncing_pubkey = base64::Engine::encode( | 731 | let syncing_pubkey = base64::Engine::encode( |
| 611 | &base64::engine::general_purpose::STANDARD, | 732 | &base64::engine::general_purpose::STANDARD, |
| 612 | syncing_vk.to_bytes(), | 733 | syncing_vk.to_bytes(), |