a73x

47dcdd3c

Add end-to-end server coverage and refine the server workflows

a73x   2026-04-03 17:23

Commit message
Add end-to-end server coverage and refine the server workflows

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> &nbsp; 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 &nbsp; 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(),