track/services/
jj_task.rs1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug, Deserialize)]
7struct JjTaskMap {
8 #[serde(default)]
9 repos: HashMap<String, JjRepoEntry>,
10}
11
12#[derive(Debug, Deserialize)]
13struct JjRepoEntry {
14 #[serde(default)]
15 tasks: HashMap<String, JjTaskEntry>,
16}
17
18#[derive(Debug, Deserialize)]
19struct JjTaskEntry {
20 workspace: String,
21 #[serde(default)]
22 phase: String,
23}
24
25fn map_path() -> PathBuf {
26 std::env::var("JJ_TASK_MAP")
27 .map(PathBuf::from)
28 .unwrap_or_else(|_| {
29 directories::BaseDirs::new()
30 .map(|dirs| dirs.home_dir().join(".config/jj/task-workspaces.json"))
31 .unwrap_or_else(|| PathBuf::from(".config/jj/task-workspaces.json"))
32 })
33}
34
35fn load_map() -> Option<JjTaskMap> {
36 let path = map_path();
37 let content = std::fs::read_to_string(path).ok()?;
38 serde_json::from_str(&content).ok()
39}
40
41pub fn repo_key(repo_path: &str) -> String {
43 let output = Command::new("git")
44 .args(["-C", repo_path, "remote", "get-url", "origin"])
45 .output();
46
47 if let Ok(output) = output {
48 if output.status.success() {
49 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
50 if let Some(key) = normalize_remote_url(&url) {
51 return key;
52 }
53 }
54 }
55
56 std::fs::canonicalize(repo_path)
57 .unwrap_or_else(|_| PathBuf::from(repo_path))
58 .to_string_lossy()
59 .into_owned()
60}
61
62fn normalize_remote_url(url: &str) -> Option<String> {
63 let stripped = url
64 .trim()
65 .strip_prefix("https://")
66 .or_else(|| url.strip_prefix("git@"))
67 .or_else(|| url.strip_prefix("ssh://"))
68 .unwrap_or(url);
69
70 let stripped = stripped
71 .strip_prefix("github.com:")
72 .or_else(|| stripped.strip_prefix("github.com/"))
73 .unwrap_or(stripped);
74
75 let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
76 let key = stripped.to_ascii_lowercase();
77 if key.contains('/') && key != url {
78 Some(key)
79 } else {
80 None
81 }
82}
83
84pub fn workspace_path(repo_path: &str, slug: &str) -> Option<String> {
86 let map = load_map()?;
87 let key = repo_key(repo_path);
88 map.repos
89 .get(&key)
90 .and_then(|repo| repo.tasks.get(slug))
91 .map(|task| task.workspace.clone())
92}
93
94pub fn slug_registered(slug: &str, repo_paths: &[String]) -> bool {
96 let Some(map) = load_map() else {
97 return false;
98 };
99
100 repo_paths.iter().any(|repo_path| {
101 let key = repo_key(repo_path);
102 map.repos
103 .get(&key)
104 .and_then(|repo| repo.tasks.get(slug))
105 .is_some()
106 })
107}
108
109pub fn task_phase(slug: &str, repo_paths: &[String]) -> Option<String> {
111 let map = load_map()?;
112 for repo_path in repo_paths {
113 let key = repo_key(repo_path);
114 if let Some(entry) = map.repos.get(&key).and_then(|repo| repo.tasks.get(slug)) {
115 return Some(entry.phase.clone());
116 }
117 }
118 None
119}
120
121pub fn expected_workspace_path(repo_path: &str, slug: &str) -> String {
123 Path::new(repo_path)
124 .join(".worktrees")
125 .join(slug)
126 .to_string_lossy()
127 .into_owned()
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn normalize_github_https_url() {
136 assert_eq!(
137 normalize_remote_url("https://github.com/manji-0/track.git"),
138 Some("manji-0/track".to_string())
139 );
140 }
141
142 #[test]
143 fn normalize_github_ssh_url() {
144 assert_eq!(
145 normalize_remote_url("git@github.com:manji-0/track.git"),
146 Some("manji-0/track".to_string())
147 );
148 }
149
150 #[test]
151 fn expected_workspace_path_uses_worktrees_dir() {
152 assert_eq!(
153 expected_workspace_path("/repo/app", "fix-auth"),
154 "/repo/app/.worktrees/fix-auth"
155 );
156 }
157}