1use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use anyhow::{bail, Result};
10
11use crate::{
12 cache::SessionSnapshot,
13 config::global::GlobalConfig,
14 git::{info as git_info, worktree as git_worktree},
15 hooks,
16 model::workspace::{
17 session_display_name_from_tmux, FetchFailReason, ForegroundKind, GitInfo, Project,
18 ProjectConfig, SessionInfo, WorkspaceState, WorktreeInfo,
19 },
20 tmux::{monitor::SessionStatus, session},
21};
22
23type PaneSnap = HashMap<String, (Option<String>, bool)>;
25type WorktreeSnap = HashMap<PathBuf, WorktreeSnapEntry>;
27
28struct WorktreeSnapEntry {
29 git_info: Option<GitInfo>,
30 git_info_fetched_at: Option<Instant>,
31 expanded: bool,
32 panes: PaneSnap,
33 session_order: Vec<String>,
34 last_fetched: Option<Instant>,
35 fetch_failed: bool,
36 fetch_fail_count: u32,
37 fetch_fail_reason: Option<FetchFailReason>,
38}
39
40pub const IDLE_SECS: u64 = 3;
41
42fn is_git_repo(path: &std::path::Path) -> bool {
43 path.exists() && path.join(".git").exists()
44}
45
46fn unix_ts_to_instant(unix_ts: u64) -> Option<Instant> {
49 let now_unix = SystemTime::now()
50 .duration_since(UNIX_EPOCH)
51 .unwrap_or_default()
52 .as_secs();
53 let secs_ago = now_unix.saturating_sub(unix_ts);
54 Instant::now().checked_sub(Duration::from_secs(secs_ago))
55}
56
57pub fn refresh_workspace(
60 workspace: &mut WorkspaceState,
61 config: &GlobalConfig,
62 sessions_with_paths: &[(String, PathBuf)],
63 activity: &HashMap<String, SessionStatus>,
64) {
65 let worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)> = workspace
66 .projects
67 .iter()
68 .map(|p| {
69 let entries = git_worktree::list_worktrees(&p.path).unwrap_or_default();
70 (p.path.clone(), entries)
71 })
72 .collect();
73 refresh_workspace_with_worktrees(workspace, config, sessions_with_paths, activity, worktrees);
74}
75
76pub fn refresh_workspace_with_worktrees(
79 workspace: &mut WorkspaceState,
80 config: &GlobalConfig,
81 sessions_with_paths: &[(String, PathBuf)],
82 activity: &HashMap<String, SessionStatus>,
83 worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
84) {
85 let mut sessions_by_path: HashMap<&PathBuf, Vec<&str>> = HashMap::new();
87 for (name, path) in sessions_with_paths {
88 sessions_by_path
89 .entry(path)
90 .or_default()
91 .push(name.as_str());
92 }
93
94 let aliases_by_path: Vec<(PathBuf, HashMap<String, String>)> = config
95 .projects
96 .iter()
97 .map(|e| (e.path.clone(), e.aliases.clone()))
98 .collect();
99
100 let mut worktrees_map: HashMap<PathBuf, Vec<git_worktree::WorktreeEntry>> =
101 worktrees.into_iter().collect();
102
103 for i in 0..workspace.projects.len() {
104 let path = workspace.projects[i].path.clone();
105 let proj_name = workspace.projects[i].name.clone();
106 let aliases = aliases_by_path
107 .iter()
108 .find(|(p, _)| p == &path)
109 .map(|(_, a)| a.clone())
110 .unwrap_or_default();
111
112 let snapshot: WorktreeSnap = workspace.projects[i]
113 .worktrees
114 .iter()
115 .map(|w| {
116 let panes = w
117 .sessions
118 .iter()
119 .map(|s| (s.name.clone(), (s.pane_capture.clone(), s.muted)))
120 .collect();
121 let order = w.sessions.iter().map(|s| s.name.clone()).collect();
122 (
123 w.path.clone(),
124 WorktreeSnapEntry {
125 git_info: w.git_info.clone(),
126 git_info_fetched_at: w.git_info_fetched_at,
127 expanded: w.expanded,
128 panes,
129 session_order: order,
130 last_fetched: w.last_fetched,
131 fetch_failed: w.fetch_failed,
132 fetch_fail_count: w.fetch_fail_count,
133 fetch_fail_reason: w.fetch_fail_reason.clone(),
134 },
135 )
136 })
137 .collect();
138
139 let entries = worktrees_map.remove(&path).unwrap_or_default();
140 let mut new_worktrees = Vec::new();
141 for entry in entries
142 .into_iter()
143 .filter(|e| !config.is_worktree_excluded(&e.path))
144 {
145 let alias = aliases.get(&entry.branch).cloned();
146 let wt_path = entry.path.clone();
147 let prev = snapshot.get(&entry.path);
148
149 let prev_order: &[String] = prev
150 .map(|snap| snap.session_order.as_slice())
151 .unwrap_or(&[]);
152 let order_index: HashMap<&str, usize> = prev_order
154 .iter()
155 .enumerate()
156 .map(|(i, n)| (n.as_str(), i))
157 .collect();
158
159 let empty_names: Vec<&str> = Vec::new();
160 let session_names = sessions_by_path.get(&wt_path).unwrap_or(&empty_names);
161 let mut sessions: Vec<SessionInfo> = session_names
162 .iter()
163 .map(|&name| {
164 let display_name = session_display_name_from_tmux(
165 name,
166 &proj_name,
167 &wt_path,
168 &entry.branch,
169 alias.as_deref(),
170 );
171 let prev_pane = prev.and_then(|snap| snap.panes.get(name));
172 let (pane_capture, prev_muted) = prev_pane
173 .map(|(p, m)| (p.clone(), *m))
174 .unwrap_or((None, false));
175 let status = activity.get(name);
176 let muted = status.map(|s| s.wsx_muted).unwrap_or(prev_muted);
178 let last_activity = status
179 .filter(|s| s.last_activity_ts > 0)
180 .and_then(|s| unix_ts_to_instant(s.last_activity_ts));
181 let (has_activity, last_activity) = if muted {
185 (false, None)
186 } else {
187 (status.map(|s| s.has_bell).unwrap_or(false), last_activity)
188 };
189 SessionInfo {
190 name: name.to_string(),
191 display_name,
192 has_activity,
193 pane_capture,
194 last_activity,
195 foreground: status
196 .map(|s| s.foreground)
197 .unwrap_or(ForegroundKind::Unknown),
198 is_running_wsx: status.map(|s| s.is_running_wsx).unwrap_or(false),
199 muted,
200 }
201 })
202 .collect();
203 sessions.sort_by_key(|s| *order_index.get(s.name.as_str()).unwrap_or(&usize::MAX));
204
205 let (
206 git_info,
207 git_info_fetched_at,
208 expanded,
209 last_fetched,
210 fetch_failed,
211 fetch_fail_count,
212 fetch_fail_reason,
213 ) = prev
214 .map(|snap| {
215 (
216 snap.git_info.clone(),
217 snap.git_info_fetched_at,
218 snap.expanded,
219 snap.last_fetched,
220 snap.fetch_failed,
221 snap.fetch_fail_count,
222 snap.fetch_fail_reason.clone(),
223 )
224 })
225 .unwrap_or((None, None, true, None, false, 0, None));
226
227 new_worktrees.push(WorktreeInfo {
228 name: entry.name,
229 branch: entry.branch,
230 path: entry.path,
231 is_main: entry.is_main,
232 alias,
233 sessions,
234 expanded,
235 git_info,
236 git_info_fetched_at,
237 fetch_failed,
238 fetch_fail_count,
239 fetch_fail_reason,
240 last_fetched,
241 });
242 }
243 workspace.projects[i].worktrees = new_worktrees;
244 }
245 workspace.projects.retain(|p| !p.missing);
248 for p in &mut workspace.projects {
249 p.missing = !is_git_repo(&p.path);
250 }
251}
252
253pub fn update_activity(
255 workspace: &mut WorkspaceState,
256 activity: &HashMap<String, SessionStatus>,
257) -> bool {
258 let mut changed = false;
259 for project in &mut workspace.projects {
260 for wt in &mut project.worktrees {
261 for sess in &mut wt.sessions {
262 if sess.muted {
263 continue;
264 }
265 let old_bell = sess.has_activity;
266 let old_foreground = sess.foreground;
267 if let Some(status) = activity.get(&sess.name) {
268 sess.has_activity = status.has_bell;
269 sess.foreground = status.foreground;
270 sess.is_running_wsx = status.is_running_wsx;
271 sess.last_activity = Some(status.last_activity_ts)
272 .filter(|&ts| ts > 0)
273 .and_then(|ts| unix_ts_to_instant(ts));
274 } else {
275 sess.has_activity = false;
276 sess.foreground = ForegroundKind::Unknown;
277 sess.is_running_wsx = false;
278 }
279 if sess.has_activity != old_bell || sess.foreground != old_foreground {
280 changed = true;
281 }
282 }
283 }
284 }
285 changed
286}
287
288pub fn load_workspace(config: &GlobalConfig) -> WorkspaceState {
291 if config.projects.is_empty() {
292 return WorkspaceState::empty();
293 }
294
295 let projects = config
296 .projects
297 .iter()
298 .filter_map(|entry| {
299 let path = &entry.path;
300 if !is_git_repo(path) {
301 return None;
302 }
303
304 let default_branch = detect_default_branch(path);
305 let proj_config = crate::config::project::load_project_config(path);
306 let entries = git_worktree::list_worktrees(path).unwrap_or_default();
307 let entries = entries
308 .into_iter()
309 .filter(|e| !config.is_worktree_excluded(&e.path))
310 .collect();
311 let worktrees = git_worktree::to_worktree_infos(entries, &entry.aliases);
312
313 Some(Project {
314 name: entry.name.clone(),
315 path: path.clone(),
316 default_branch,
317 worktrees,
318 config: Some(proj_config),
319 expanded: true,
320 missing: false,
321 })
322 })
323 .collect();
324
325 WorkspaceState { projects }
326}
327
328pub fn expand_path(s: &str) -> PathBuf {
329 if s.starts_with("~/") {
330 if let Some(home) = dirs::home_dir() {
331 return home.join(&s[2..]);
332 }
333 }
334 PathBuf::from(s)
335}
336
337pub fn detect_default_branch(path: &std::path::Path) -> String {
338 git_info::current_branch(path).unwrap_or_else(|| "main".into())
339}
340
341pub fn register_project(path: PathBuf, config: &mut GlobalConfig) -> Result<Project> {
346 if path.as_os_str().is_empty() {
347 bail!("empty path");
348 }
349 let path = crate::config::global::normalize_project_path(&path);
354 if !path.exists() {
355 bail!("path does not exist: {}", path.display());
356 }
357 if !is_git_repo(&path) {
358 bail!("not a git repository: {}", path.display());
359 }
360 if config.projects.iter().any(|e| e.path == path) {
361 bail!("project already registered: {}", path.display());
362 }
363
364 let name = path
365 .file_name()
366 .map(|n| n.to_string_lossy().to_string())
367 .unwrap_or_else(|| "unknown".to_string());
368
369 let default_branch = detect_default_branch(&path);
370 let proj_config = crate::config::project::load_project_config(&path);
371 let entries = git_worktree::list_worktrees(&path).unwrap_or_default();
372 let aliases = config
373 .projects
374 .iter()
375 .find(|e| e.path == path)
376 .map(|e| e.aliases.clone())
377 .unwrap_or_default();
378 let worktrees = git_worktree::to_worktree_infos(entries, &aliases);
379
380 config.add_project(name.clone(), path.clone());
381
382 Ok(Project {
383 name,
384 path,
385 default_branch,
386 worktrees,
387 config: Some(proj_config),
388 expanded: true,
389 missing: false,
390 })
391}
392
393pub fn unregister_project(path: &PathBuf, config: &mut GlobalConfig) {
395 config.remove_project(path);
396}
397
398pub fn create_worktree(
404 repo_path: &PathBuf,
405 default_branch: &str,
406 proj_config: &ProjectConfig,
407 branch: &str,
408) -> Result<(PathBuf, Option<String>)> {
409 let wt_path = git_worktree::create_worktree(repo_path, branch, default_branch)?;
410
411 let mut warning: Option<String> = None;
412
413 if let Err(e) = hooks::copy_env_files(repo_path, &wt_path, proj_config) {
414 warning = Some(format!("Warning: .env copy: {}", e));
415 }
416 if let Some(ref cmd) = proj_config.post_create {
417 if let Err(e) = hooks::run_post_create(&wt_path, cmd) {
418 warning = Some(format!("Warning: postCreate: {}", e));
419 }
420 }
421
422 Ok((wt_path, warning))
423}
424
425pub fn delete_worktree(
427 repo_path: &PathBuf,
428 wt_path: &PathBuf,
429 branch: &str,
430 session_names: &[String],
431) -> Result<()> {
432 git_worktree::remove_worktree(repo_path, wt_path, branch)?;
433 for sess in session_names {
434 let _ = session::kill_session(sess);
435 }
436 Ok(())
437}
438
439pub fn create_session(
445 proj_name: &str,
446 wt_slug: &str,
447 wt_path: &PathBuf,
448 session_name: Option<String>,
449 command: Option<String>,
450) -> Result<(String, String)> {
451 let base_display = match &session_name {
453 Some(n) if !n.is_empty() => n.clone(),
454 _ => match &command {
455 Some(cmd) => cmd
456 .split_whitespace()
457 .next()
458 .unwrap_or(proj_name)
459 .to_string(),
460 None => proj_name.to_string(),
461 },
462 };
463 let base_tmux = format!("{}-{}-{}", proj_name, wt_slug, base_display);
464 let tmux_name = session::unique_session_name(&base_tmux);
465 let prefix_len = proj_name.len() + 1 + wt_slug.len() + 1;
467 let display_name = tmux_name[prefix_len..].to_string();
468 session::create_session(&tmux_name, wt_path)?;
469 if let Some(cmd) = command {
470 session::send_keys(&tmux_name, &cmd)?;
471 }
472 Ok((tmux_name, display_name))
473}
474
475pub fn rename_session(old_name: &str, new_name: &str) -> Result<()> {
477 session::rename_session(old_name, new_name)
478}
479
480pub fn restore_cached_sessions(workspace: &WorkspaceState, cached_pid: Option<u32>) -> usize {
490 let current_pid = session::server_pid();
491 if cached_pid.is_some() && cached_pid == current_pid {
492 return 0;
493 }
494
495 let live: HashSet<String> = session::list_sessions_with_paths()
496 .into_iter()
497 .map(|(name, _)| name)
498 .collect();
499
500 let source = choose_restore_source(
501 crate::cache::load_session_snapshot_with_meta(),
502 crate::cache::collect_session_names(workspace),
503 crate::cache::WorkspaceCache::load().written_at_unix_ms,
504 );
505
506 let mut restored = 0usize;
507 for (path_str, names) in &source {
508 let path = std::path::Path::new(path_str.as_str());
509 for name in names {
510 if !live.contains(name) && session::create_session(name, path).is_ok() {
511 restored += 1;
512 }
513 }
514 }
515 restored
516}
517
518fn choose_restore_source(
519 snapshot: SessionSnapshot,
520 workspace_sessions: HashMap<String, Vec<String>>,
521 workspace_written_at: Option<u64>,
522) -> HashMap<String, Vec<String>> {
523 let snapshot_is_newer = match (snapshot.written_at_unix_ms, workspace_written_at) {
524 (None, _) => true,
527 (Some(_), None) => true,
528 (Some(snapshot_ms), Some(workspace_ms)) => snapshot_ms >= workspace_ms,
529 };
530 if !snapshot.sessions.is_empty() && (workspace_sessions.is_empty() || snapshot_is_newer) {
531 snapshot.sessions
532 } else {
533 workspace_sessions
534 }
535}
536
537pub fn set_alias(config: &mut GlobalConfig, proj_path: &PathBuf, branch: &str, alias: &str) {
541 config.set_alias(proj_path, branch, alias);
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::model::workspace::{Project, WorkspaceState};
548 use std::collections::HashMap;
549
550 fn make_project(path: PathBuf) -> Project {
551 Project {
552 name: "test".to_string(),
553 path,
554 default_branch: "main".to_string(),
555 worktrees: vec![],
556 config: None,
557 expanded: true,
558 missing: false,
559 }
560 }
561
562 fn sessions(path: &str, names: &[&str]) -> HashMap<String, Vec<String>> {
563 HashMap::from([(
564 path.to_string(),
565 names.iter().map(|name| name.to_string()).collect(),
566 )])
567 }
568
569 #[test]
570 fn restore_source_uses_workspace_when_snapshot_is_older() {
571 let source = choose_restore_source(
572 SessionSnapshot {
573 sessions: sessions("/tmp/repo", &["old"]),
574 written_at_unix_ms: Some(10),
575 },
576 sessions("/tmp/repo", &["new"]),
577 Some(20),
578 );
579
580 assert_eq!(source["/tmp/repo"], vec!["new"]);
581 }
582
583 #[test]
584 fn restore_source_uses_snapshot_when_snapshot_is_newer() {
585 let source = choose_restore_source(
586 SessionSnapshot {
587 sessions: sessions("/tmp/repo", &["new"]),
588 written_at_unix_ms: Some(20),
589 },
590 sessions("/tmp/repo", &["old"]),
591 Some(10),
592 );
593
594 assert_eq!(source["/tmp/repo"], vec!["new"]);
595 }
596
597 #[test]
598 fn restore_source_keeps_legacy_snapshot_preference() {
599 let source = choose_restore_source(
600 SessionSnapshot {
601 sessions: sessions("/tmp/repo", &["legacy"]),
602 written_at_unix_ms: None,
603 },
604 sessions("/tmp/repo", &["workspace"]),
605 Some(20),
606 );
607
608 assert_eq!(source["/tmp/repo"], vec!["legacy"]);
609 }
610
611 #[test]
612 fn refresh_drops_project_whose_directory_was_deleted() {
613 let suffix = std::time::SystemTime::now()
614 .duration_since(std::time::UNIX_EPOCH)
615 .unwrap_or_default()
616 .as_nanos();
617 let base = std::env::temp_dir().join(format!("wsx-test-{}", suffix));
618 std::fs::create_dir_all(&base).unwrap();
619 let exists_path = base.join("real");
620 std::fs::create_dir_all(&exists_path).unwrap();
621 std::fs::create_dir(exists_path.join(".git")).unwrap();
622 let missing_path = base.join("ghost");
623
624 let config = GlobalConfig::default();
625 let activity: HashMap<String, crate::tmux::monitor::SessionStatus> = HashMap::new();
626
627 let mut workspace = WorkspaceState {
628 projects: vec![
629 make_project(exists_path.clone()),
630 make_project(missing_path.clone()),
631 ],
632 };
633
634 refresh_workspace_with_worktrees(
636 &mut workspace,
637 &config,
638 &[],
639 &activity,
640 vec![
641 (exists_path.clone(), vec![]),
642 (missing_path.clone(), vec![]),
643 ],
644 );
645 assert_eq!(workspace.projects.len(), 2);
646 assert!(workspace
647 .projects
648 .iter()
649 .any(|p| p.missing && p.path == missing_path));
650
651 refresh_workspace_with_worktrees(
653 &mut workspace,
654 &config,
655 &[],
656 &activity,
657 vec![(exists_path.clone(), vec![]), (missing_path, vec![])],
658 );
659 assert_eq!(workspace.projects.len(), 1);
660 assert_eq!(workspace.projects[0].path, exists_path);
661 let _ = std::fs::remove_dir_all(&base);
662 }
663
664 fn unique_base() -> PathBuf {
665 let suffix = std::time::SystemTime::now()
666 .duration_since(std::time::UNIX_EPOCH)
667 .unwrap_or_default()
668 .as_nanos();
669 let base = std::env::temp_dir().join(format!("wsx-test-register-{}", suffix));
670 std::fs::create_dir_all(&base).unwrap();
671 base
672 }
673
674 fn make_repo_dir(base: &std::path::Path, name: &str) -> PathBuf {
675 let p = base.join(name);
676 std::fs::create_dir_all(p.join(".git")).unwrap();
677 p
678 }
679
680 #[test]
681 fn given_trailing_slash_path_when_registered_then_returned_path_has_no_trailing_slash() {
682 let base = unique_base();
683 let repo = make_repo_dir(&base, "myrepo");
684 let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
685 let mut config = GlobalConfig::default();
686
687 let project = register_project(with_slash, &mut config).unwrap();
688
689 assert_eq!(project.path, repo);
690 let _ = std::fs::remove_dir_all(&base);
691 }
692
693 #[test]
694 fn given_valid_repo_when_registered_then_name_is_final_path_component() {
695 let base = unique_base();
696 let repo = make_repo_dir(&base, "coolproject");
697 let mut config = GlobalConfig::default();
698
699 let project = register_project(repo, &mut config).unwrap();
700
701 assert_eq!(project.name, "coolproject");
702 let _ = std::fs::remove_dir_all(&base);
703 }
704
705 #[test]
706 fn given_valid_repo_when_registered_then_appended_to_config() {
707 let base = unique_base();
708 let repo = make_repo_dir(&base, "myrepo");
709 let mut config = GlobalConfig::default();
710
711 let _ = register_project(repo, &mut config).unwrap();
712
713 assert_eq!(config.projects.len(), 1);
714 let _ = std::fs::remove_dir_all(&base);
715 }
716
717 #[test]
718 fn given_two_distinct_repos_when_both_registered_then_both_stored() {
719 let base = unique_base();
720 let repo_a = make_repo_dir(&base, "alpha");
721 let repo_b = make_repo_dir(&base, "beta");
722 let mut config = GlobalConfig::default();
723
724 register_project(repo_a, &mut config).unwrap();
725 register_project(repo_b, &mut config).unwrap();
726
727 assert_eq!(config.projects.len(), 2);
728 let _ = std::fs::remove_dir_all(&base);
729 }
730
731 #[test]
732 fn given_same_exact_path_registered_twice_when_second_call_then_returns_err() {
733 let base = unique_base();
734 let repo = make_repo_dir(&base, "myrepo");
735 let mut config = GlobalConfig::default();
736
737 register_project(repo.clone(), &mut config).unwrap();
738 let second = register_project(repo, &mut config);
739
740 assert!(second.is_err());
741 let _ = std::fs::remove_dir_all(&base);
742 }
743
744 #[test]
745 fn given_same_exact_path_registered_twice_when_second_call_then_projects_len_stays_one() {
746 let base = unique_base();
747 let repo = make_repo_dir(&base, "myrepo");
748 let mut config = GlobalConfig::default();
749
750 register_project(repo.clone(), &mut config).unwrap();
751 let _ = register_project(repo, &mut config);
752
753 assert_eq!(config.projects.len(), 1);
754 let _ = std::fs::remove_dir_all(&base);
755 }
756
757 #[test]
758 fn given_path_differing_only_by_trailing_slash_when_second_call_then_returns_err() {
759 let base = unique_base();
760 let repo = make_repo_dir(&base, "myrepo");
761 let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
762 let mut config = GlobalConfig::default();
763
764 register_project(repo, &mut config).unwrap();
765 let second = register_project(with_slash, &mut config);
766
767 assert!(second.is_err());
768 let _ = std::fs::remove_dir_all(&base);
769 }
770
771 #[test]
772 fn given_path_differing_only_by_trailing_slash_when_second_call_then_projects_len_stays_one() {
773 let base = unique_base();
774 let repo = make_repo_dir(&base, "myrepo");
775 let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
776 let mut config = GlobalConfig::default();
777
778 register_project(repo, &mut config).unwrap();
779 let _ = register_project(with_slash, &mut config);
780
781 assert_eq!(config.projects.len(), 1);
782 let _ = std::fs::remove_dir_all(&base);
783 }
784
785 #[test]
786 fn given_empty_path_when_registered_then_returns_err() {
787 let mut config = GlobalConfig::default();
788
789 let result = register_project(PathBuf::from(""), &mut config);
790
791 assert!(result.is_err());
792 }
793
794 #[test]
795 fn given_empty_path_when_registered_then_projects_stays_empty() {
796 let mut config = GlobalConfig::default();
797
798 let _ = register_project(PathBuf::from(""), &mut config);
799
800 assert_eq!(config.projects.len(), 0);
801 }
802
803 #[test]
804 fn given_nonexistent_path_when_registered_then_returns_err() {
805 let base = unique_base();
806 let missing = base.join("does-not-exist");
807 let mut config = GlobalConfig::default();
808
809 let result = register_project(missing, &mut config);
810
811 assert!(result.is_err());
812 let _ = std::fs::remove_dir_all(&base);
813 }
814
815 #[test]
816 fn given_existing_dir_without_git_when_registered_then_returns_err() {
817 let base = unique_base();
818 let plain = base.join("plaindir");
819 std::fs::create_dir_all(&plain).unwrap();
820 let mut config = GlobalConfig::default();
821
822 let result = register_project(plain, &mut config);
823
824 assert!(result.is_err());
825 let _ = std::fs::remove_dir_all(&base);
826 }
827
828 #[test]
829 fn given_file_at_path_instead_of_dir_when_registered_then_returns_err() {
830 let base = unique_base();
831 let file_path = base.join("notadir");
832 std::fs::write(&file_path, b"i am a file").unwrap();
833 let mut config = GlobalConfig::default();
834
835 let result = register_project(file_path, &mut config);
836
837 assert!(result.is_err());
838 let _ = std::fs::remove_dir_all(&base);
839 }
840}