Skip to main content

track/services/
jj_task.rs

1use 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
41/// Normalizes a git remote URL to owner/repo (same logic as jj-task.sh).
42pub 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
84/// Returns the jj-task workspace path for a slug in a repo, if registered.
85pub 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
94/// Returns true when jj-task has registered the slug for any of the given repo paths.
95pub 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
109/// Returns jj-task phase for a slug in the first matching repo, if any.
110pub 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
121/// Default workspace path following agent-skill-jj layout (before jj-task start).
122pub 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}