track/services/jj_task/
mod.rs1mod map;
4
5use map::load_map;
6use serde::Serialize;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10pub fn repo_key(repo_path: &str) -> String {
12 let output = Command::new("git")
13 .args(["-C", repo_path, "remote", "get-url", "origin"])
14 .output();
15
16 if let Ok(output) = output {
17 if output.status.success() {
18 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
19 if let Some(key) = normalize_remote_url(&url) {
20 return key;
21 }
22 }
23 }
24
25 std::fs::canonicalize(repo_path)
26 .unwrap_or_else(|_| PathBuf::from(repo_path))
27 .to_string_lossy()
28 .into_owned()
29}
30
31fn normalize_remote_url(url: &str) -> Option<String> {
32 let stripped = url
33 .trim()
34 .strip_prefix("https://")
35 .or_else(|| url.strip_prefix("git@"))
36 .or_else(|| url.strip_prefix("ssh://"))
37 .unwrap_or(url);
38
39 let stripped = stripped
40 .strip_prefix("github.com:")
41 .or_else(|| stripped.strip_prefix("github.com/"))
42 .unwrap_or(stripped);
43
44 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
45 let key = stripped.to_ascii_lowercase();
46 if key.contains('/') && key != url {
47 Some(key)
48 } else {
49 None
50 }
51}
52
53pub fn workspace_path(repo_path: &str, slug: &str) -> Option<String> {
55 let map = load_map()?;
56 let key = repo_key(repo_path);
57 map.repos
58 .get(&key)
59 .and_then(|repo| repo.tasks.get(slug))
60 .map(|task| task.workspace.clone())
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct RepoWorkspaceStatus {
66 pub repo_path: String,
67 pub registered: bool,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub workspace_path: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub phase: Option<String>,
72}
73
74pub fn repos_workspace_status(slug: &str, repo_paths: &[String]) -> Vec<RepoWorkspaceStatus> {
76 let map = load_map();
77 repo_paths
78 .iter()
79 .map(|repo_path| {
80 let key = repo_key(repo_path);
81 let entry = map
82 .as_ref()
83 .and_then(|m| m.repos.get(&key))
84 .and_then(|repo| repo.tasks.get(slug));
85 RepoWorkspaceStatus {
86 repo_path: repo_path.clone(),
87 registered: entry.is_some(),
88 workspace_path: entry.map(|task| task.workspace.clone()),
89 phase: entry
90 .map(|task| task.phase.clone())
91 .filter(|phase| !phase.is_empty()),
92 }
93 })
94 .collect()
95}
96
97pub fn all_repos_registered(slug: &str, repo_paths: &[String]) -> bool {
99 if repo_paths.is_empty() {
100 return false;
101 }
102 repos_workspace_status(slug, repo_paths)
103 .iter()
104 .all(|status| status.registered)
105}
106
107pub fn unregistered_repo_paths(slug: &str, repo_paths: &[String]) -> Vec<String> {
109 repos_workspace_status(slug, repo_paths)
110 .into_iter()
111 .filter(|status| !status.registered)
112 .map(|status| status.repo_path)
113 .collect()
114}
115
116pub fn repo_initialized(repo_path: &str) -> bool {
118 let Some(map) = load_map() else {
119 return false;
120 };
121 map.repos.contains_key(&repo_key(repo_path))
122}
123
124pub fn active_registrations(slug: &str, repo_paths: &[String]) -> Vec<RepoWorkspaceStatus> {
126 repos_workspace_status(slug, repo_paths)
127 .into_iter()
128 .filter(|status| status.registered && status.phase.as_deref() != Some("done"))
129 .collect()
130}
131
132pub fn active_workspace_paths(slug: &str, repo_paths: &[String]) -> Vec<String> {
134 active_registrations(slug, repo_paths)
135 .into_iter()
136 .filter_map(|status| status.workspace_path)
137 .collect()
138}
139
140pub fn slug_registered(slug: &str, repo_paths: &[String]) -> bool {
142 let Some(map) = load_map() else {
143 return false;
144 };
145
146 repo_paths.iter().any(|repo_path| {
147 let key = repo_key(repo_path);
148 map.repos
149 .get(&key)
150 .and_then(|repo| repo.tasks.get(slug))
151 .is_some()
152 })
153}
154
155pub fn task_phase(slug: &str, repo_paths: &[String]) -> Option<String> {
157 let map = load_map()?;
158 for repo_path in repo_paths {
159 let key = repo_key(repo_path);
160 if let Some(entry) = map.repos.get(&key).and_then(|repo| repo.tasks.get(slug)) {
161 return Some(entry.phase.clone());
162 }
163 }
164 None
165}
166
167pub fn expected_workspace_path(repo_path: &str, slug: &str) -> String {
169 Path::new(repo_path)
170 .join(".worktrees")
171 .join(slug)
172 .to_string_lossy()
173 .into_owned()
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn normalize_github_https_url() {
182 assert_eq!(
183 normalize_remote_url("https://github.com/manji-0/track.git"),
184 Some("manji-0/track".to_string())
185 );
186 }
187
188 #[test]
189 fn normalize_github_ssh_url() {
190 assert_eq!(
191 normalize_remote_url("git@github.com:manji-0/track.git"),
192 Some("manji-0/track".to_string())
193 );
194 }
195
196 #[test]
197 fn active_registrations_excludes_done_phase() {
198 let statuses = vec![
199 RepoWorkspaceStatus {
200 repo_path: "/a".into(),
201 registered: true,
202 workspace_path: Some("/a/.worktrees/slug".into()),
203 phase: Some("done".into()),
204 },
205 RepoWorkspaceStatus {
206 repo_path: "/b".into(),
207 registered: true,
208 workspace_path: Some("/b/.worktrees/slug".into()),
209 phase: Some("active".into()),
210 },
211 ];
212
213 let active: Vec<_> = statuses
214 .into_iter()
215 .filter(|s| s.registered && s.phase.as_deref() != Some("done"))
216 .collect();
217 assert_eq!(active.len(), 1);
218 assert_eq!(active[0].repo_path, "/b");
219 }
220
221 #[test]
222 fn expected_workspace_path_uses_worktrees_dir() {
223 assert_eq!(
224 expected_workspace_path("/repo/app", "fix-auth"),
225 "/repo/app/.worktrees/fix-auth"
226 );
227 }
228}