Skip to main content

murk_cli/
git.rs

1//! Git integration helpers (merge driver setup).
2
3use std::fs;
4use std::io::Write;
5use std::path::Path;
6use std::process::Command;
7
8/// The `.gitattributes` line that enables the merge driver.
9const GITATTRIBUTES_LINE: &str = "*.murk merge=murk";
10
11/// Git config keys for the merge driver.
12const GIT_CONFIG_MERGE_NAME: &str = "merge.murk.name";
13const GIT_CONFIG_MERGE_DRIVER: &str = "merge.murk.driver";
14
15/// A step completed during merge driver setup.
16#[derive(Debug, PartialEq, Eq)]
17pub enum MergeDriverSetupStep {
18    /// `.gitattributes` already contained the merge driver entry.
19    GitattributesAlreadyExists,
20    /// Appended the merge driver entry to an existing `.gitattributes`.
21    GitattributesAppended,
22    /// Created a new `.gitattributes` file with the merge driver entry.
23    GitattributesCreated,
24    /// Configured `git config merge.murk.*`.
25    GitConfigured,
26}
27
28/// Configure git to use murk's custom merge driver for `.murk` files.
29///
30/// 1. Ensures `.gitattributes` contains `*.murk merge=murk`.
31/// 2. Runs `git config merge.murk.name` and `git config merge.murk.driver`.
32///
33/// Returns the steps that were performed.
34pub fn setup_merge_driver() -> Result<Vec<MergeDriverSetupStep>, String> {
35    let mut steps = Vec::new();
36
37    // 1. Write .gitattributes entry.
38    let gitattributes = Path::new(".gitattributes");
39    let merge_line = GITATTRIBUTES_LINE;
40
41    crate::env::reject_symlink(gitattributes, ".gitattributes")?;
42
43    if gitattributes.exists() {
44        let contents = fs::read_to_string(gitattributes)
45            .map_err(|e| format!("reading .gitattributes: {e}"))?;
46        if contents.contains(merge_line) {
47            steps.push(MergeDriverSetupStep::GitattributesAlreadyExists);
48        } else {
49            let mut file = fs::OpenOptions::new()
50                .append(true)
51                .open(gitattributes)
52                .map_err(|e| format!("writing .gitattributes: {e}"))?;
53            writeln!(file, "{merge_line}").map_err(|e| format!("writing .gitattributes: {e}"))?;
54            steps.push(MergeDriverSetupStep::GitattributesAppended);
55        }
56    } else {
57        fs::write(gitattributes, format!("{merge_line}\n"))
58            .map_err(|e| format!("writing .gitattributes: {e}"))?;
59        steps.push(MergeDriverSetupStep::GitattributesCreated);
60    }
61
62    // 2. Configure git merge driver.
63    let configs = [
64        (GIT_CONFIG_MERGE_NAME, "murk vault merge"),
65        (GIT_CONFIG_MERGE_DRIVER, "murk merge-driver %O %A %B"),
66    ];
67    for (key, value) in &configs {
68        let status = Command::new("git")
69            .args(["config", key, value])
70            .status()
71            .map_err(|e| format!("running git config: {e}"))?;
72        if !status.success() {
73            return Err(format!("git config {key} failed (are you in a git repo?)"));
74        }
75    }
76    steps.push(MergeDriverSetupStep::GitConfigured);
77
78    Ok(steps)
79}
80
81/// Signature status of the most recent commit that modified `path`.
82///
83/// The vault signature authenticates *content*; a signed commit authenticates
84/// *who landed it* — together they anchor integrity in git (see `THREAT_MODEL`).
85/// `murk verify` surfaces this so a team can confirm the vault's history is
86/// signed, not just its bytes.
87#[derive(Debug, PartialEq, Eq)]
88pub enum CommitSignature {
89    /// A good, verified signature.
90    Good,
91    /// A signature is present but git could not validate it (unknown/expired key).
92    Unverified,
93    /// A bad signature — the commit was altered or the signature doesn't match.
94    Bad,
95    /// The commit carries no signature.
96    Unsigned,
97}
98
99/// Return the signature status of the last commit touching `path`, or `None`
100/// when git is unavailable, the repo has no such commit, or the path is
101/// untracked — i.e. there is no git anchor to check.
102pub fn last_commit_signature(path: &str) -> Option<CommitSignature> {
103    let output = Command::new("git")
104        .args(["log", "-1", "--format=%G?", "--", path])
105        .output()
106        .ok()?;
107    if !output.status.success() {
108        return None;
109    }
110    match String::from_utf8(output.stdout).ok()?.trim() {
111        "G" => Some(CommitSignature::Good),
112        // U (good, unknown validity), X (expired), Y (expired key), E (cannot
113        // check) all mean "a signature exists but we can't fully vouch for it".
114        "U" | "X" | "Y" | "E" => Some(CommitSignature::Unverified),
115        "B" | "R" => Some(CommitSignature::Bad),
116        "N" => Some(CommitSignature::Unsigned),
117        // Empty: no commit for this path (untracked / no history) — no anchor.
118        _ => None,
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::testutil::CWD_LOCK;
126
127    #[test]
128    fn setup_merge_driver_creates_gitattributes() {
129        let _lock = CWD_LOCK
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner);
132        let dir = std::env::temp_dir().join("murk_test_git_setup");
133        let _ = std::fs::remove_dir_all(&dir);
134        std::fs::create_dir_all(&dir).unwrap();
135
136        // Init a git repo so git config works.
137        Command::new("git")
138            .args(["init"])
139            .current_dir(&dir)
140            .output()
141            .unwrap();
142
143        let original_dir = std::env::current_dir().unwrap();
144        std::env::set_current_dir(&dir).unwrap();
145
146        let steps = setup_merge_driver().unwrap();
147        assert!(steps.contains(&MergeDriverSetupStep::GitattributesCreated));
148        assert!(steps.contains(&MergeDriverSetupStep::GitConfigured));
149
150        let contents = std::fs::read_to_string(dir.join(".gitattributes")).unwrap();
151        assert!(contents.contains("*.murk merge=murk"));
152
153        std::env::set_current_dir(original_dir).unwrap();
154        std::fs::remove_dir_all(&dir).unwrap();
155    }
156
157    #[test]
158    fn setup_merge_driver_appends_gitattributes() {
159        let _lock = CWD_LOCK
160            .lock()
161            .unwrap_or_else(std::sync::PoisonError::into_inner);
162        let dir = std::env::temp_dir().join("murk_test_git_append");
163        let _ = std::fs::remove_dir_all(&dir);
164        std::fs::create_dir_all(&dir).unwrap();
165
166        Command::new("git")
167            .args(["init"])
168            .current_dir(&dir)
169            .output()
170            .unwrap();
171
172        std::fs::write(dir.join(".gitattributes"), "*.txt text\n").unwrap();
173
174        let original_dir = std::env::current_dir().unwrap();
175        std::env::set_current_dir(&dir).unwrap();
176
177        let steps = setup_merge_driver().unwrap();
178        assert!(steps.contains(&MergeDriverSetupStep::GitattributesAppended));
179
180        let contents = std::fs::read_to_string(dir.join(".gitattributes")).unwrap();
181        assert!(contents.contains("*.txt text"));
182        assert!(contents.contains("*.murk merge=murk"));
183
184        std::env::set_current_dir(original_dir).unwrap();
185        std::fs::remove_dir_all(&dir).unwrap();
186    }
187
188    #[test]
189    fn setup_merge_driver_already_exists() {
190        let _lock = CWD_LOCK
191            .lock()
192            .unwrap_or_else(std::sync::PoisonError::into_inner);
193        let dir = std::env::temp_dir().join("murk_test_git_exists");
194        let _ = std::fs::remove_dir_all(&dir);
195        std::fs::create_dir_all(&dir).unwrap();
196
197        Command::new("git")
198            .args(["init"])
199            .current_dir(&dir)
200            .output()
201            .unwrap();
202
203        std::fs::write(dir.join(".gitattributes"), "*.murk merge=murk\n").unwrap();
204
205        let original_dir = std::env::current_dir().unwrap();
206        std::env::set_current_dir(&dir).unwrap();
207
208        let steps = setup_merge_driver().unwrap();
209        assert!(steps.contains(&MergeDriverSetupStep::GitattributesAlreadyExists));
210
211        std::env::set_current_dir(original_dir).unwrap();
212        std::fs::remove_dir_all(&dir).unwrap();
213    }
214}