Skip to main content

agent_vault/core/
git.rs

1use std::path::{Path, PathBuf};
2
3use git2::{Repository, Signature};
4
5use crate::error::VaultError;
6
7/// Content for .agent-vault/.gitignore
8pub fn gitignore_content() -> &'static str {
9    "# agent-vault: block unencrypted key material\n\
10     *.key\n\
11     *.pem\n\
12     **/private.*\n\
13     !**/*.escrow\n"
14}
15
16/// Pre-commit hook script that blocks commits containing unencrypted private key material.
17pub fn pre_commit_hook_script() -> &'static str {
18    r#"#!/bin/sh
19# agent-vault pre-commit hook: block unencrypted private key material
20PATTERNS='AGE-SECRET-KEY-\|-----BEGIN PRIVATE KEY-----\|-----BEGIN RSA PRIVATE KEY-----\|-----BEGIN EC PRIVATE KEY-----\|-----BEGIN OPENSSH PRIVATE KEY-----'
21
22if git diff --cached --diff-filter=ACM -z --name-only | \
23   xargs -0 grep -l "$PATTERNS" 2>/dev/null; then
24    echo ""
25    echo "ERROR: Commit blocked by agent-vault pre-commit hook."
26    echo "Staged files contain unencrypted private key material."
27    echo "Remove the private key material before committing."
28    exit 1
29fi
30exit 0
31"#
32}
33
34/// Open an existing git repository at the given path.
35pub fn open_repo(path: &Path) -> Result<Repository, VaultError> {
36    let repo = Repository::discover(path)?;
37    Ok(repo)
38}
39
40/// Stage files and create a commit.
41pub fn commit_files(repo: &Repository, paths: &[PathBuf], message: &str) -> Result<(), VaultError> {
42    let mut index = repo.index()?;
43
44    let workdir = repo
45        .workdir()
46        .ok_or_else(|| VaultError::Git(git2::Error::from_str("bare repository")))?;
47
48    // Canonicalize workdir to handle symlinks (e.g., /var -> /private/var on macOS)
49    let workdir_canonical = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
50
51    for path in paths {
52        // Canonicalize the file path too, then strip the workdir prefix
53        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
54        let relative = canonical
55            .strip_prefix(&workdir_canonical)
56            .unwrap_or(&canonical);
57        index.add_path(relative)?;
58    }
59    index.write()?;
60
61    let tree_id = index.write_tree()?;
62    let tree = repo.find_tree(tree_id)?;
63
64    let sig = Signature::now("agent-vault", "agent-vault@localhost")?;
65
66    // Check if there's a HEAD commit to use as parent
67    let parent_commit = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
68
69    match parent_commit {
70        Some(parent) => {
71            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
72        }
73        None => {
74            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
75        }
76    };
77
78    Ok(())
79}
80
81/// Pull latest from the remote (if one exists). Best-effort; silently skips if no remote.
82pub fn pull(repo: &Repository) -> Result<(), VaultError> {
83    // Only pull if there's a remote named "origin"
84    let remote = match repo.find_remote("origin") {
85        Ok(r) => r,
86        Err(_) => return Ok(()), // no remote, skip
87    };
88    let remote_name = remote.name().unwrap_or("origin").to_string();
89    drop(remote);
90
91    // Fetch
92    let mut remote = repo.find_remote(&remote_name)?;
93    remote.fetch(&[] as &[&str], None, None)?;
94
95    // Try to fast-forward merge the current branch
96    let fetch_head = match repo.find_reference("FETCH_HEAD") {
97        Ok(r) => r,
98        Err(_) => return Ok(()), // no FETCH_HEAD (empty remote)
99    };
100    let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
101
102    let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
103    if analysis.is_fast_forward() {
104        if let Ok(mut head_ref) = repo.head() {
105            let msg = format!("Fast-forward to {}", fetch_commit.id());
106            head_ref.set_target(fetch_commit.id(), &msg)?;
107            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
108        }
109    }
110    // If not fast-forward or up-to-date, do nothing (don't attempt merge)
111
112    Ok(())
113}
114
115/// Remove a directory from the git index by its relative path within the vault.
116/// `relative_path` should be relative to the repo root (e.g., ".agent-vault/agents/bot1").
117pub fn remove_dir_from_index(repo: &Repository, relative_path: &Path) -> Result<(), VaultError> {
118    let mut index = repo.index()?;
119    index.remove_dir(relative_path, 0)?;
120    index.write()?;
121    Ok(())
122}
123
124/// Install the pre-commit hook in the repository's hooks directory.
125pub fn install_pre_commit_hook(repo: &Repository) -> Result<(), VaultError> {
126    let hooks_dir = repo.path().join("hooks");
127    std::fs::create_dir_all(&hooks_dir)?;
128
129    let hook_path = hooks_dir.join("pre-commit");
130
131    // Don't overwrite an existing hook
132    if hook_path.exists() {
133        let existing = std::fs::read_to_string(&hook_path)?;
134        if existing.contains("agent-vault") {
135            return Ok(());
136        }
137        // Append to existing hook
138        let combined = format!("{existing}\n{}", pre_commit_hook_script());
139        std::fs::write(&hook_path, combined)?;
140    } else {
141        std::fs::write(&hook_path, pre_commit_hook_script())?;
142    }
143
144    // Make executable
145    #[cfg(unix)]
146    {
147        use std::os::unix::fs::PermissionsExt;
148        std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
149    }
150
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_pre_commit_hook_contains_all_patterns() {
160        let script = pre_commit_hook_script();
161        assert!(script.contains("AGE-SECRET-KEY-"));
162        assert!(script.contains("BEGIN PRIVATE KEY"));
163        assert!(script.contains("BEGIN RSA PRIVATE KEY"));
164        assert!(script.contains("BEGIN EC PRIVATE KEY"));
165        assert!(script.contains("BEGIN OPENSSH PRIVATE KEY"));
166    }
167}