Skip to main content

chronicle/hooks/
mod.rs

1pub mod post_rewrite;
2pub mod prepare_commit_msg;
3
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7use crate::annotate::gather::AuthorContext;
8use crate::error::chronicle_error::{IoSnafu, JsonSnafu};
9use crate::error::Result;
10use snafu::ResultExt;
11
12const HOOK_BEGIN_MARKER: &str = "# --- chronicle hook begin ---";
13const HOOK_END_MARKER: &str = "# --- chronicle hook end ---";
14
15const POST_COMMIT_SCRIPT: &str = r#"# --- chronicle hook begin ---
16# Installed by chronicle. Do not edit between these markers.
17if command -v git-chronicle >/dev/null 2>&1; then
18    git-chronicle annotate --commit HEAD --sync &
19fi
20# --- chronicle hook end ---"#;
21
22const PREPARE_COMMIT_MSG_SCRIPT: &str = r#"# --- chronicle hook begin ---
23# Installed by chronicle. Do not edit between these markers.
24if command -v git-chronicle >/dev/null 2>&1; then
25    git-chronicle hook prepare-commit-msg "$@"
26fi
27# --- chronicle hook end ---"#;
28
29const POST_REWRITE_SCRIPT: &str = r#"# --- chronicle hook begin ---
30# Installed by chronicle. Do not edit between these markers.
31if command -v git-chronicle >/dev/null 2>&1; then
32    git-chronicle hook post-rewrite "$@"
33fi
34# --- chronicle hook end ---"#;
35
36/// Pending context stored in .git/chronicle/pending-context.json.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PendingContext {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub task: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub reasoning: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub dependencies: Option<String>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub tags: Vec<String>,
47}
48
49impl PendingContext {
50    pub fn to_author_context(&self) -> AuthorContext {
51        AuthorContext {
52            task: self.task.clone(),
53            reasoning: self.reasoning.clone(),
54            dependencies: self.dependencies.clone(),
55            tags: self.tags.clone(),
56        }
57    }
58}
59
60fn pending_context_path(git_dir: &Path) -> std::path::PathBuf {
61    git_dir.join("chronicle").join("pending-context.json")
62}
63
64/// Read pending context from .git/chronicle/pending-context.json.
65pub fn read_pending_context(git_dir: &Path) -> Result<Option<PendingContext>> {
66    let path = pending_context_path(git_dir);
67    if !path.exists() {
68        return Ok(None);
69    }
70    let contents = std::fs::read_to_string(&path).context(IoSnafu)?;
71    let ctx: PendingContext = serde_json::from_str(&contents).context(JsonSnafu)?;
72    Ok(Some(ctx))
73}
74
75/// Write pending context to .git/chronicle/pending-context.json.
76pub fn write_pending_context(git_dir: &Path, ctx: &PendingContext) -> Result<()> {
77    let path = pending_context_path(git_dir);
78    if let Some(parent) = path.parent() {
79        std::fs::create_dir_all(parent).context(IoSnafu)?;
80    }
81    let json = serde_json::to_string_pretty(ctx).context(JsonSnafu)?;
82    std::fs::write(&path, json).context(IoSnafu)?;
83    Ok(())
84}
85
86/// Delete the pending context file.
87pub fn delete_pending_context(git_dir: &Path) -> Result<()> {
88    let path = pending_context_path(git_dir);
89    if path.exists() {
90        std::fs::remove_file(&path).context(IoSnafu)?;
91    }
92    Ok(())
93}
94
95/// Install a single hook script into the hooks directory.
96fn install_single_hook(hooks_dir: &Path, hook_name: &str, script: &str) -> Result<()> {
97    let hook_path = hooks_dir.join(hook_name);
98
99    let existing = if hook_path.exists() {
100        std::fs::read_to_string(&hook_path).context(IoSnafu)?
101    } else {
102        String::new()
103    };
104
105    let new_content = if existing.contains(HOOK_BEGIN_MARKER) {
106        // Replace existing chronicle section
107        let mut result = String::new();
108        let mut in_section = false;
109        for line in existing.lines() {
110            if line.contains(HOOK_BEGIN_MARKER) {
111                in_section = true;
112                result.push_str(script);
113                result.push('\n');
114                continue;
115            }
116            if line.contains(HOOK_END_MARKER) {
117                in_section = false;
118                continue;
119            }
120            if !in_section {
121                result.push_str(line);
122                result.push('\n');
123            }
124        }
125        result
126    } else if existing.is_empty() {
127        format!("#!/bin/sh\n{script}\n")
128    } else {
129        let mut content = existing.clone();
130        if !content.ends_with('\n') {
131            content.push('\n');
132        }
133        content.push('\n');
134        content.push_str(script);
135        content.push('\n');
136        content
137    };
138
139    std::fs::write(&hook_path, &new_content).context(IoSnafu)?;
140
141    #[cfg(unix)]
142    {
143        use std::os::unix::fs::PermissionsExt;
144        let perms = std::fs::Permissions::from_mode(0o755);
145        std::fs::set_permissions(&hook_path, perms).context(IoSnafu)?;
146    }
147
148    Ok(())
149}
150
151/// Install all chronicle hooks: post-commit, prepare-commit-msg, and post-rewrite.
152pub fn install_hooks(git_dir: &Path) -> Result<()> {
153    let hooks_dir = git_dir.join("hooks");
154    std::fs::create_dir_all(&hooks_dir).context(IoSnafu)?;
155
156    install_single_hook(&hooks_dir, "post-commit", POST_COMMIT_SCRIPT)?;
157    install_single_hook(&hooks_dir, "prepare-commit-msg", PREPARE_COMMIT_MSG_SCRIPT)?;
158    install_single_hook(&hooks_dir, "post-rewrite", POST_REWRITE_SCRIPT)?;
159
160    Ok(())
161}