1use std::path::{Path, PathBuf};
14use std::sync::{Arc, Weak};
15
16use async_trait::async_trait;
17use forge_primitives::git_diff::RepoGate;
18
19use crate::SessionSlot;
20use crate::mcp::caller_context::CallerContext;
21use crate::mcp::review::{CommentAnchorView, ReviewDetail, ReviewSummary};
22use crate::workspace::Workspace;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct ReviewScope {
31 pub project: String,
32 pub branch: String,
33 pub author_label: String,
34 pub caller: SessionSlot,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum ScopeError {
44 WorkspaceGone,
46 UnknownCaller,
48 SessionCwdUnknown,
51 ScanDirMissing { scan_cwd: PathBuf },
53 NoBranchFromGit { scan_cwd: PathBuf, gate: RepoGate },
60 DetachedHead { scan_cwd: PathBuf },
62}
63
64impl ScopeError {
65 fn step(&self) -> &'static str {
69 match self {
70 Self::WorkspaceGone => "workspace_upgrade",
71 Self::UnknownCaller => "caller_context",
72 Self::SessionCwdUnknown => "session_cwd",
73 Self::ScanDirMissing { .. } => "scan_dir",
74 Self::NoBranchFromGit { .. } => "git_branch",
75 Self::DetachedHead { .. } => "git_head",
76 }
77 }
78
79 fn scan_cwd(&self) -> Option<&Path> {
82 match self {
83 Self::WorkspaceGone | Self::UnknownCaller | Self::SessionCwdUnknown => None,
84 Self::ScanDirMissing { scan_cwd }
85 | Self::NoBranchFromGit { scan_cwd, .. }
86 | Self::DetachedHead { scan_cwd } => Some(scan_cwd),
87 }
88 }
89
90 fn gate(&self) -> Option<RepoGate> {
93 match self {
94 Self::NoBranchFromGit { gate, .. } => Some(*gate),
95 Self::DetachedHead { .. } => Some(RepoGate::InRepo),
96 _ => None,
97 }
98 }
99
100 pub fn message(&self) -> String {
104 match self {
105 Self::WorkspaceGone => {
106 "forge is shutting down, so your review scope could not be resolved.".to_owned()
107 }
108 Self::UnknownCaller => "your session is not registered under any forge project, so \
109 there is no project to key reviews by."
110 .to_owned(),
111 Self::SessionCwdUnknown => "forge has no working directory recorded for your session, \
112 so it cannot tell which checkout you are in."
113 .to_owned(),
114 Self::ScanDirMissing { scan_cwd } => {
115 format!("the checkout forge has you in, {}, is not on disk.", scan_cwd.display())
116 }
117 Self::NoBranchFromGit { scan_cwd, .. } => format!(
118 "git reported no branch for {} - it is either not a work tree or the git call \
119 failed; forge's log carries git's own error.",
120 scan_cwd.display(),
121 ),
122 Self::DetachedHead { scan_cwd } => format!(
123 "the checkout at {} is on a detached HEAD, so it has no branch name to key \
124 reviews by.",
125 scan_cwd.display(),
126 ),
127 }
128 }
129}
130
131#[async_trait]
135pub trait ReviewFacade: Send + Sync {
136 async fn resolve_scope(
141 &self,
142 caller: &SessionSlot,
143 scope: Option<&str>,
144 ) -> Result<ReviewScope, ScopeError>;
145
146 fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String>;
148
149 fn branches_with_reviews(&self, scope: &ReviewScope) -> Vec<String>;
153
154 fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String>;
157
158 fn reply(
162 &self,
163 scope: &ReviewScope,
164 comment_id: &str,
165 text: &str,
166 at: &str,
167 ) -> Result<CommentAnchorView, String>;
168
169 fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String>;
172}
173
174pub struct ProdReviewFacade(pub Weak<Workspace>);
177
178impl ProdReviewFacade {
179 pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn ReviewFacade> {
181 Arc::new(Self(Arc::downgrade(workspace)))
182 }
183}
184
185fn warn_unresolved(
189 caller: &SessionSlot,
190 cx: Option<&CallerContext>,
191 cwd_raw: Option<&str>,
192 error: ScopeError,
193) -> ScopeError {
194 tracing::warn!(
195 target: "forge_workspace::review",
196 event_name = "review_scope_unresolved",
197 step = error.step(),
198 caller = %caller.display(),
199 project = cx.map_or("-", |c| c.project_name.as_str()),
200 worker_label = cx.and_then(|c| c.worker_label.as_deref()).unwrap_or("-"),
201 cwd_raw = cwd_raw.unwrap_or("-"),
202 scan_cwd = %error.scan_cwd().unwrap_or(Path::new("-")).display(),
203 gate = ?error.gate(),
204 message = %error.message(),
205 );
206 error
207}
208
209#[async_trait]
210impl ReviewFacade for ProdReviewFacade {
211 async fn resolve_scope(
212 &self,
213 caller: &SessionSlot,
214 scope: Option<&str>,
215 ) -> Result<ReviewScope, ScopeError> {
216 let Some(ws) = self.0.upgrade() else {
217 return Err(warn_unresolved(caller, None, None, ScopeError::WorkspaceGone));
218 };
219 let Some(cx) = crate::mcp::caller_context::caller_context(&ws, caller) else {
220 return Err(warn_unresolved(caller, None, None, ScopeError::UnknownCaller));
221 };
222 let author_label = cx.worker_label.as_deref().unwrap_or("agent").to_owned();
223 if let Some(branch) = scope {
226 return Ok(ReviewScope {
227 project: cx.project_name,
228 branch: branch.to_owned(),
229 author_label,
230 caller: caller.clone(),
231 });
232 }
233 let Some(cwd_raw) = ws.cwd_for_session(caller) else {
238 return Err(warn_unresolved(caller, Some(&cx), None, ScopeError::SessionCwdUnknown));
239 };
240 let scan_cwd = ws.git_scan_cwd_for_session(caller, Path::new(&cwd_raw));
241 let fail = |error| Err(warn_unresolved(caller, Some(&cx), Some(cwd_raw.as_str()), error));
242 if !scan_cwd.is_dir() {
243 return fail(ScopeError::ScanDirMissing { scan_cwd });
244 }
245 let branch = match forge_agent::env::git_diff::current_branch(&scan_cwd).await {
246 Ok(Some(branch)) => branch,
247 Ok(None) => return fail(ScopeError::DetachedHead { scan_cwd }),
248 Err(gate) => return fail(ScopeError::NoBranchFromGit { scan_cwd, gate }),
249 };
250 Ok(ReviewScope { project: cx.project_name, branch, author_label, caller: caller.clone() })
251 }
252
253 fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String> {
254 let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
255 ws.review_list(&scope.project, &scope.branch)
256 }
257
258 fn branches_with_reviews(&self, scope: &ReviewScope) -> Vec<String> {
262 let Some(ws) = self.0.upgrade() else {
263 return Vec::new();
264 };
265 ws.review_branches(&scope.project).unwrap_or_else(|error| {
266 tracing::warn!(
267 target: "forge_workspace::review",
268 event_name = "review_branch_probe_failed",
269 project = %scope.project,
270 %error,
271 );
272 Vec::new()
273 })
274 }
275
276 fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String> {
277 let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
278 ws.review_get(&scope.project, &scope.branch, review_id)
279 }
280
281 fn reply(
282 &self,
283 scope: &ReviewScope,
284 comment_id: &str,
285 text: &str,
286 at: &str,
287 ) -> Result<CommentAnchorView, String> {
288 let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
289 ws.review_reply(
290 &scope.caller,
291 &scope.project,
292 &scope.branch,
293 comment_id,
294 &scope.author_label,
295 text,
296 at,
297 )
298 }
299
300 fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String> {
301 let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
302 ws.review_resolve(&scope.caller, &scope.project, &scope.branch, comment_id)
303 }
304}
305
306#[cfg(test)]
310pub struct MockReviewFacade {
311 pub scope: parking_lot::Mutex<Result<ReviewScope, ScopeError>>,
313 pub summaries: parking_lot::Mutex<Vec<ReviewSummary>>,
314 pub tree_summaries: parking_lot::Mutex<Vec<ReviewSummary>>,
317 pub review_branches: parking_lot::Mutex<Vec<String>>,
319 pub detail: parking_lot::Mutex<Option<ReviewDetail>>,
320 pub tree_detail: parking_lot::Mutex<Option<ReviewDetail>>,
322 pub fail_non_tree: parking_lot::Mutex<bool>,
325 pub tree_list_error: parking_lot::Mutex<Option<String>>,
328 pub reply_calls: parking_lot::Mutex<Vec<(String, String)>>,
330 pub resolve_calls: parking_lot::Mutex<Vec<String>>,
332 pub reply_anchor: parking_lot::Mutex<CommentAnchorView>,
334 pub resolve_anchor: parking_lot::Mutex<CommentAnchorView>,
336 pub force_error: parking_lot::Mutex<Option<String>>,
338 pub scopes_seen: parking_lot::Mutex<Vec<String>>,
341}
342
343#[cfg(test)]
346fn on_seat_tree(scope: &ReviewScope) -> bool {
347 forge_primitives::review::is_tree_scope(&scope.branch)
348}
349
350#[cfg(test)]
351impl MockReviewFacade {
352 pub fn new() -> Self {
353 Self {
354 scope: parking_lot::Mutex::new(Ok(ReviewScope {
355 project: "forge".to_owned(),
356 branch: "feat".to_owned(),
357 author_label: "implementer".to_owned(),
358 caller: SessionSlot::from_str_for_test("caller"),
359 })),
360 summaries: parking_lot::Mutex::new(Vec::new()),
361 tree_summaries: parking_lot::Mutex::new(Vec::new()),
362 review_branches: parking_lot::Mutex::new(Vec::new()),
363 detail: parking_lot::Mutex::new(None),
364 tree_detail: parking_lot::Mutex::new(None),
365 fail_non_tree: parking_lot::Mutex::new(false),
366 tree_list_error: parking_lot::Mutex::new(None),
367 reply_calls: parking_lot::Mutex::new(Vec::new()),
368 resolve_calls: parking_lot::Mutex::new(Vec::new()),
369 reply_anchor: parking_lot::Mutex::new(CommentAnchorView {
370 comment_id: "c1".to_owned(),
371 file: "src/x.rs".to_owned(),
372 line: 12,
373 side: "new",
374 status: "addressed",
375 number: Some(1),
376 }),
377 resolve_anchor: parking_lot::Mutex::new(CommentAnchorView {
378 comment_id: "c2".to_owned(),
379 file: "src/x.rs".to_owned(),
380 line: 12,
381 side: "new",
382 status: "resolved",
383 number: Some(1),
384 }),
385 force_error: parking_lot::Mutex::new(None),
386 scopes_seen: parking_lot::Mutex::new(Vec::new()),
387 }
388 }
389
390 pub fn into_arc(self) -> Arc<dyn ReviewFacade> {
391 Arc::new(self)
392 }
393}
394
395#[cfg(test)]
396#[async_trait]
397impl ReviewFacade for MockReviewFacade {
398 async fn resolve_scope(
399 &self,
400 _caller: &SessionSlot,
401 scope: Option<&str>,
402 ) -> Result<ReviewScope, ScopeError> {
403 let mut resolved = self.scope.lock().clone()?;
404 if let Some(branch) = scope {
405 branch.clone_into(&mut resolved.branch);
406 }
407 Ok(resolved)
408 }
409
410 fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String> {
411 self.scopes_seen.lock().push(scope.branch.clone());
412 if on_seat_tree(scope) {
413 if let Some(err) = self.tree_list_error.lock().clone() {
414 return Err(err);
415 }
416 return Ok(self.tree_summaries.lock().clone());
417 }
418 Ok(self.summaries.lock().clone())
419 }
420
421 fn branches_with_reviews(&self, _scope: &ReviewScope) -> Vec<String> {
422 self.review_branches.lock().clone()
423 }
424
425 fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String> {
426 self.scopes_seen.lock().push(scope.branch.clone());
427 let held = if on_seat_tree(scope) { self.tree_detail.lock() } else { self.detail.lock() };
428 Ok(held.clone().filter(|d| d.review_id == review_id))
429 }
430
431 fn reply(
432 &self,
433 scope: &ReviewScope,
434 comment_id: &str,
435 text: &str,
436 _at: &str,
437 ) -> Result<CommentAnchorView, String> {
438 self.scopes_seen.lock().push(scope.branch.clone());
439 if let Some(err) = self.force_error.lock().clone() {
440 return Err(err);
441 }
442 if *self.fail_non_tree.lock() && !on_seat_tree(scope) {
443 return Err(format!("comment {comment_id} is not in this scope"));
444 }
445 self.reply_calls.lock().push((comment_id.to_owned(), text.to_owned()));
446 Ok(self.reply_anchor.lock().clone())
447 }
448
449 fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String> {
450 self.scopes_seen.lock().push(scope.branch.clone());
451 if let Some(err) = self.force_error.lock().clone() {
452 return Err(err);
453 }
454 if *self.fail_non_tree.lock() && !on_seat_tree(scope) {
455 return Err(format!("comment {comment_id} is not in this scope"));
456 }
457 self.resolve_calls.lock().push(comment_id.to_owned());
458 Ok(self.resolve_anchor.lock().clone())
459 }
460}
461
462#[cfg(test)]
466mod resolve_scope_tests {
467 use super::{ProdReviewFacade, ReviewFacade, ReviewScope, ScopeError};
468 use crate::SessionSlot;
469 use crate::workspace::Workspace;
470 use std::path::{Path, PathBuf};
471 use std::sync::{Arc, Weak};
472
473 #[test]
474 fn branches_with_reviews_reports_the_projects_other_branches() {
475 let dir = tempfile::tempdir().expect("tempdir");
476 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
477 ws.install_db_for_test(
478 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
479 );
480 let reviewer = SessionSlot::from_str_for_test("lead-uuid");
481 ws.submit_review("myproj", "feat/theirs", None, &[], reviewer.clone())
482 .expect("submit theirs");
483 ws.submit_review("other", "feat/elsewhere", None, &[], reviewer).expect("submit elsewhere");
484 let scope = ReviewScope {
485 project: "myproj".to_owned(),
486 branch: "feat/mine".to_owned(),
487 author_label: "implementer".to_owned(),
488 caller: SessionSlot::from_str_for_test("caller-uuid"),
489 };
490 assert_eq!(
491 ProdReviewFacade(Arc::downgrade(&ws)).branches_with_reviews(&scope),
492 vec!["feat/theirs".to_owned()],
493 "only this project's review-bearing branches",
494 );
495 }
496
497 fn git(dir: &Path, args: &[&str]) {
498 let out = std::process::Command::new("git")
499 .arg("-C")
500 .arg(dir)
501 .args(args)
502 .output()
503 .expect("git runs");
504 assert!(
505 out.status.success(),
506 "git {args:?} in {}: {}",
507 dir.display(),
508 String::from_utf8_lossy(&out.stderr),
509 );
510 }
511
512 fn init_repo(dir: &Path, branch: &str) {
515 git(dir, &["init", "-q"]);
516 git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
517 git(dir, &["config", "user.email", "test@example.com"]);
518 git(dir, &["config", "user.name", "Test"]);
519 git(dir, &["config", "commit.gpgsign", "false"]);
520 std::fs::write(dir.join("README.md"), "hi\n").expect("write");
521 git(dir, &["add", "."]);
522 git(dir, &["commit", "-q", "-m", "init"]);
523 }
524
525 fn add_worktree(root: &Path, label: &str) -> PathBuf {
528 let path = root.join(".claude/worktrees").join(label);
529 git(
530 root,
531 &["worktree", "add", "-q", "-b", &format!("worktree-{label}"), &path.to_string_lossy()],
532 );
533 path
534 }
535
536 fn ws_with_session_cwd(cwd: &str) -> (Arc<Workspace>, SessionSlot) {
541 let mut config = crate::config::LoadedConfig::empty_for_test();
542 config.projects.push(crate::config::LoadedProject {
543 name: "myproj".to_owned(),
544 path: PathBuf::from(cwd),
545 display_path: cwd.to_owned(),
546 org: "TestOrg".to_owned(),
547 accounts: Vec::new(),
548 fallback_accounts: Vec::new(),
549 model: None,
550 env: std::collections::HashMap::new(),
551 env_file: None,
552 max_workers: None,
553 permission_mode: forge_primitives::permission::PermissionMode::Auto,
554 document_roots: Vec::new(),
555 handoff: None,
556 browser_profile: forge_primitives::browser::ProfileKey::of("TestOrg", "default")
557 .expect("a profile key"),
558 slack_workspace: None,
559 });
560 let (ws, _rx) = Workspace::testing_stub_with_config(
561 PathBuf::from("/tmp/forge-review-testing-stub"),
562 config,
563 )
564 .expect("the stub config's [[slack]] entries are well-formed");
565 (ws, SessionSlot::lead("TestOrg", "myproj"))
566 }
567
568 fn seed_worker(
574 ws: &Arc<Workspace>,
575 project_root: &str,
576 label: &str,
577 is_git_repo_at_spawn: bool,
578 ) -> SessionSlot {
579 ws.seed_test_project("myproj", project_root);
580 let key = ws
581 .list_projects()
582 .into_iter()
583 .find(|v| v.name == "myproj")
584 .map(|v| v.key)
585 .expect("seeded project");
586 let caller = SessionSlot::worker("TestOrg", "myproj", label);
587 ws.insert_live_worker(
588 &key,
589 crate::WorkerEntry {
590 label: label.to_owned(),
591 charter: "build".to_owned(),
592 slot: caller.clone(),
593 session_id: None,
594 status: forge_primitives::WorkerLiveness::Running,
595 spawned_at: std::time::SystemTime::UNIX_EPOCH,
596 spawned_by: SessionSlot::lead("TestOrg", "myproj"),
597 needs_tag: false,
598 is_git_repo_at_spawn,
599 cwd: None,
600 diagnostic: None,
601 kick: None,
602 },
603 );
604 caller
605 }
606
607 async fn scope_err(ws: &Arc<Workspace>, caller: &SessionSlot) -> ScopeError {
608 ProdReviewFacade(Arc::downgrade(ws))
609 .resolve_scope(caller, None)
610 .await
611 .expect_err("scope resolution must fail")
612 }
613
614 #[tokio::test]
619 async fn an_explicit_scope_resolves_without_a_checkout() {
620 let dir = tempfile::tempdir().expect("tempdir");
621 let cwd = dir.path().to_string_lossy().into_owned();
622 let (ws, caller) = ws_with_session_cwd(&cwd);
623 let scope = ProdReviewFacade(Arc::downgrade(&ws))
624 .resolve_scope(&caller, Some("doc:/proj/plan.md"))
625 .await
626 .expect("an explicit scope needs no branch");
627 assert_eq!(scope.project, "myproj", "the project still resolves through the caller");
628 assert_eq!(scope.branch, "doc:/proj/plan.md");
629 assert_eq!(scope.author_label, "agent");
630 }
631
632 #[tokio::test]
635 async fn an_explicit_scope_still_requires_a_known_caller() {
636 let (ws, _rx) = Workspace::testing_stub();
637 let err = ProdReviewFacade(Arc::downgrade(&ws))
638 .resolve_scope(&SessionSlot::from_str_for_test("ghost-uuid"), Some("doc:/x"))
639 .await
640 .expect_err("an explicit scope does not bypass the caller step");
641 assert_eq!(err, ScopeError::UnknownCaller);
642 }
643
644 #[tokio::test]
645 async fn workspace_gone_is_its_own_reason() {
646 let err = ProdReviewFacade(Weak::new())
647 .resolve_scope(&SessionSlot::from_str_for_test("caller-uuid"), None)
648 .await
649 .expect_err("a dropped workspace fails");
650 assert_eq!(err, ScopeError::WorkspaceGone);
651 assert!(!err.message().contains("detached"), "{}", err.message());
652 }
653
654 #[tokio::test]
655 async fn caller_outside_every_project_is_its_own_reason() {
656 let (ws, _rx) = Workspace::testing_stub();
657 let err = scope_err(&ws, &SessionSlot::from_str_for_test("ghost-uuid")).await;
658 assert_eq!(err, ScopeError::UnknownCaller);
659 assert!(!err.message().contains("detached"), "{}", err.message());
660 }
661
662 #[tokio::test]
667 async fn worker_without_a_catalog_row_reads_its_worktree_branch() {
668 let dir = tempfile::tempdir().expect("tempdir");
669 let root = dir.path().join("myproj");
670 std::fs::create_dir_all(&root).expect("mkdir");
671 init_repo(&root, "main");
672 add_worktree(&root, "pyth-review-fixes");
673
674 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
675 ws.install_db_for_test(
676 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
677 );
678 let caller = seed_worker(&ws, &root.to_string_lossy(), "pyth-review-fixes", true);
679 ws.submit_review(
680 "myproj",
681 "worktree-pyth-review-fixes",
682 Some("round 1".to_owned()),
683 &[],
684 SessionSlot::from_str_for_test("lead-uuid"),
685 )
686 .expect("submit review on the worker's branch");
687
688 let facade = ProdReviewFacade(Arc::downgrade(&ws));
689 let scope = facade.resolve_scope(&caller, None).await.expect("worker scope resolves");
690 assert_eq!(scope.branch, "worktree-pyth-review-fixes");
691 assert_eq!(scope.author_label, "pyth-review-fixes");
692 assert_eq!(
693 facade.list(&scope).expect("list").len(),
694 1,
695 "the worker reads the review filed on its worktree branch",
696 );
697 }
698
699 #[tokio::test]
703 async fn non_git_worker_without_a_catalog_row_resolves_the_project_root() {
704 let dir = tempfile::tempdir().expect("tempdir");
705 let (ws, _rx) = Workspace::testing_stub();
706 let caller = seed_worker(&ws, &dir.path().to_string_lossy(), "researcher", false);
707 let err = scope_err(&ws, &caller).await;
708 assert!(
709 matches!(&err, ScopeError::NoBranchFromGit { scan_cwd, .. } if scan_cwd == dir.path()),
710 "the cwd step must resolve to the project root: {err:?}",
711 );
712 }
713
714 #[tokio::test]
718 async fn non_git_scan_dir_is_its_own_reason() {
719 let dir = tempfile::tempdir().expect("tempdir");
720 let cwd = dir.path().to_string_lossy().into_owned();
721 let (ws, caller) = ws_with_session_cwd(&cwd);
722 let err = scope_err(&ws, &caller).await;
723 assert!(
724 matches!(&err, ScopeError::NoBranchFromGit { scan_cwd, .. } if scan_cwd == dir.path()),
725 "{err:?}",
726 );
727 assert!(err.message().contains(&cwd), "the message names the dir it inspected");
728 assert!(!err.message().contains("detached"), "{}", err.message());
729 }
730
731 #[tokio::test]
732 async fn a_scan_dir_that_is_not_on_disk_is_its_own_reason() {
733 let dir = tempfile::tempdir().expect("tempdir");
734 let gone = dir.path().join("gone");
735 let cwd = gone.to_string_lossy().into_owned();
736 let (ws, caller) = ws_with_session_cwd(&cwd);
737 let err = scope_err(&ws, &caller).await;
738 assert_eq!(err, ScopeError::ScanDirMissing { scan_cwd: gone });
739 assert!(err.message().contains(&cwd), "the message names the dir it looked for");
740 assert!(!err.message().contains("detached"), "{}", err.message());
741 }
742
743 #[tokio::test]
744 async fn detached_head_names_the_checkout_it_inspected() {
745 let dir = tempfile::tempdir().expect("tempdir");
746 init_repo(dir.path(), "main");
747 git(dir.path(), &["checkout", "-q", "--detach"]);
748 let cwd = dir.path().to_string_lossy().into_owned();
749 let (ws, caller) = ws_with_session_cwd(&cwd);
750 let err = scope_err(&ws, &caller).await;
751 assert_eq!(err, ScopeError::DetachedHead { scan_cwd: dir.path().to_path_buf() });
752 assert!(err.message().contains("detached HEAD"), "{}", err.message());
753 assert!(err.message().contains(&cwd), "the message names the checkout it inspected");
754 }
755
756 #[tokio::test]
757 async fn a_named_branch_resolves_to_a_scope() {
758 let dir = tempfile::tempdir().expect("tempdir");
759 init_repo(dir.path(), "feat/wgpu-plotter");
760 let cwd = dir.path().to_string_lossy().into_owned();
761 let (ws, caller) = ws_with_session_cwd(&cwd);
762 let scope = ProdReviewFacade(Arc::downgrade(&ws))
763 .resolve_scope(&caller, None)
764 .await
765 .expect("a named branch resolves");
766 assert_eq!(scope.project, "myproj");
767 assert_eq!(scope.branch, "feat/wgpu-plotter");
768 assert_eq!(scope.author_label, "agent", "a non-worker caller falls back to 'agent'");
769 assert_eq!(scope.caller, caller);
770 }
771}