Skip to main content

mur_core/workflow/
versioning.rs

1//! Git-based workflow version control — track, list, and restore workflow versions.
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9/// A single version entry from the git history of a workflow file.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct WorkflowVersion {
12    /// Git commit hash (short).
13    pub version: String,
14    /// Author of the commit.
15    pub author: String,
16    /// Timestamp of the commit.
17    pub timestamp: DateTime<Utc>,
18    /// Commit message / diff summary.
19    pub diff: String,
20}
21
22/// Get the versioned workflows repository path.
23fn versioned_repo_path() -> PathBuf {
24    directories::BaseDirs::new()
25        .map(|d| d.home_dir().join(".mur").join("workflows"))
26        .unwrap_or_else(|| PathBuf::from(".mur/workflows"))
27}
28
29/// Ensure the workflows directory is a git repository.
30fn ensure_git_repo(repo_path: &Path) -> Result<()> {
31    if !repo_path.exists() {
32        std::fs::create_dir_all(repo_path)
33            .with_context(|| format!("Creating workflows directory {:?}", repo_path))?;
34    }
35
36    let git_dir = repo_path.join(".git");
37    if !git_dir.exists() {
38        let output = Command::new("git")
39            .args(["init"])
40            .current_dir(repo_path)
41            .output()
42            .context("Running git init")?;
43
44        if !output.status.success() {
45            let stderr = String::from_utf8_lossy(&output.stderr);
46            anyhow::bail!("git init failed: {}", stderr);
47        }
48    }
49    Ok(())
50}
51
52/// Auto-commit workflow changes to the git repository at `~/.mur/workflows/`.
53///
54/// Creates or updates the workflow file and commits the change.
55pub fn version_workflow(workflow_id: &str, yaml_content: &str) -> Result<WorkflowVersion> {
56    let repo_path = versioned_repo_path();
57    ensure_git_repo(&repo_path)?;
58
59    let file_path = repo_path.join(format!("{}.yaml", workflow_id));
60    std::fs::write(&file_path, yaml_content)
61        .with_context(|| format!("Writing workflow file {:?}", file_path))?;
62
63    // Stage the file
64    let output = Command::new("git")
65        .args(["add", &format!("{}.yaml", workflow_id)])
66        .current_dir(&repo_path)
67        .output()
68        .context("git add")?;
69
70    if !output.status.success() {
71        anyhow::bail!("git add failed: {}", String::from_utf8_lossy(&output.stderr));
72    }
73
74    // Check if there are staged changes
75    let status = Command::new("git")
76        .args(["diff", "--cached", "--quiet"])
77        .current_dir(&repo_path)
78        .status()
79        .context("git diff --cached")?;
80
81    if status.success() {
82        // No changes to commit
83        anyhow::bail!("No changes to version for workflow '{}'", workflow_id);
84    }
85
86    // Commit
87    let message = format!("Update workflow: {}", workflow_id);
88    let output = Command::new("git")
89        .args(["commit", "-m", &message, "--author", "mur-commander <mur@localhost>"])
90        .current_dir(&repo_path)
91        .output()
92        .context("git commit")?;
93
94    if !output.status.success() {
95        anyhow::bail!(
96            "git commit failed: {}",
97            String::from_utf8_lossy(&output.stderr)
98        );
99    }
100
101    // Get the commit info
102    let log_output = Command::new("git")
103        .args(["log", "-1", "--format=%H%n%an%n%aI%n%s"])
104        .current_dir(&repo_path)
105        .output()
106        .context("git log")?;
107
108    let log_str = String::from_utf8_lossy(&log_output.stdout);
109    let lines: Vec<&str> = log_str.trim().lines().collect();
110
111    let version = lines.first().unwrap_or(&"unknown").to_string();
112    let author = lines.get(1).unwrap_or(&"unknown").to_string();
113    let timestamp = lines
114        .get(2)
115        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
116        .map(|dt| dt.with_timezone(&Utc))
117        .unwrap_or_else(Utc::now);
118    let diff = lines.get(3).unwrap_or(&"").to_string();
119
120    Ok(WorkflowVersion {
121        version,
122        author,
123        timestamp,
124        diff,
125    })
126}
127
128/// List all versions (git log) for a workflow file.
129pub fn list_versions(workflow_id: &str) -> Result<Vec<WorkflowVersion>> {
130    let repo_path = versioned_repo_path();
131    let file_name = format!("{}.yaml", workflow_id);
132
133    if !repo_path.join(".git").exists() {
134        return Ok(vec![]);
135    }
136
137    let output = Command::new("git")
138        .args([
139            "log",
140            "--format=%H%n%an%n%aI%n%s%n---",
141            "--follow",
142            "--",
143            &file_name,
144        ])
145        .current_dir(&repo_path)
146        .output()
147        .context("git log")?;
148
149    if !output.status.success() {
150        return Ok(vec![]);
151    }
152
153    let log_str = String::from_utf8_lossy(&output.stdout);
154    let mut versions = Vec::new();
155
156    for entry in log_str.split("---\n") {
157        let entry = entry.trim();
158        if entry.is_empty() {
159            continue;
160        }
161        let lines: Vec<&str> = entry.lines().collect();
162        if lines.len() < 4 {
163            continue;
164        }
165
166        let version = lines[0].to_string();
167        let author = lines[1].to_string();
168        let timestamp = DateTime::parse_from_rfc3339(lines[2])
169            .map(|dt| dt.with_timezone(&Utc))
170            .unwrap_or_else(|_| Utc::now());
171        let diff = lines[3].to_string();
172
173        versions.push(WorkflowVersion {
174            version,
175            author,
176            timestamp,
177            diff,
178        });
179    }
180
181    Ok(versions)
182}
183
184/// Restore a workflow file to a specific git version.
185pub fn restore_version(workflow_id: &str, version: &str) -> Result<String> {
186    let repo_path = versioned_repo_path();
187    let file_name = format!("{}.yaml", workflow_id);
188
189    if !repo_path.join(".git").exists() {
190        anyhow::bail!("No version history found for workflows");
191    }
192
193    // Checkout the file at the specified version
194    let output = Command::new("git")
195        .args(["checkout", version, "--", &file_name])
196        .current_dir(&repo_path)
197        .output()
198        .context("git checkout")?;
199
200    if !output.status.success() {
201        anyhow::bail!(
202            "Failed to restore version '{}': {}",
203            version,
204            String::from_utf8_lossy(&output.stderr)
205        );
206    }
207
208    // Read the restored content
209    let content = std::fs::read_to_string(repo_path.join(&file_name))
210        .with_context(|| format!("Reading restored workflow '{}'", file_name))?;
211
212    Ok(content)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_versioned_repo_path() {
221        let path = versioned_repo_path();
222        assert!(path.to_string_lossy().contains("workflows"));
223    }
224
225    #[test]
226    fn test_list_versions_no_repo() {
227        // When no git repo exists, should return empty
228        let result = list_versions("nonexistent-workflow");
229        assert!(result.is_ok());
230        assert!(result.unwrap().is_empty());
231    }
232
233    #[test]
234    fn test_workflow_version_serialization() {
235        let version = WorkflowVersion {
236            version: "abc123".into(),
237            author: "test-user".into(),
238            timestamp: Utc::now(),
239            diff: "Update workflow: test".into(),
240        };
241
242        let json = serde_json::to_string(&version).unwrap();
243        assert!(json.contains("abc123"));
244        assert!(json.contains("test-user"));
245    }
246
247    #[test]
248    fn test_version_and_list_roundtrip() {
249        let tmp = tempfile::tempdir().unwrap();
250        let repo = tmp.path().join("workflows");
251        std::fs::create_dir_all(&repo).unwrap();
252
253        // Init a git repo manually in the temp dir
254        let output = Command::new("git")
255            .args(["init"])
256            .current_dir(&repo)
257            .output()
258            .unwrap();
259        assert!(output.status.success());
260
261        // Configure git user for the test repo
262        Command::new("git")
263            .args(["config", "user.email", "test@test.com"])
264            .current_dir(&repo)
265            .output()
266            .unwrap();
267        Command::new("git")
268            .args(["config", "user.name", "Test"])
269            .current_dir(&repo)
270            .output()
271            .unwrap();
272
273        // Write a file, add and commit
274        let file = repo.join("test-wf.yaml");
275        std::fs::write(&file, "id: test-wf\nname: Test\nsteps: []\n").unwrap();
276        Command::new("git")
277            .args(["add", "test-wf.yaml"])
278            .current_dir(&repo)
279            .output()
280            .unwrap();
281        Command::new("git")
282            .args(["commit", "-m", "Initial"])
283            .current_dir(&repo)
284            .output()
285            .unwrap();
286
287        // Verify we can parse the git log format
288        let output = Command::new("git")
289            .args(["log", "--format=%H%n%an%n%aI%n%s%n---", "--", "test-wf.yaml"])
290            .current_dir(&repo)
291            .output()
292            .unwrap();
293        let log_str = String::from_utf8_lossy(&output.stdout);
294        assert!(log_str.contains("Initial"));
295    }
296}