git_remote_htree/
lib.rs

1//! Git remote helper for hashtree
2//!
3//! This crate provides a git remote helper that allows pushing/pulling
4//! git repositories via nostr and hashtree.
5//!
6//! Usage:
7//!   git remote add origin htree://<pubkey>/<repo-name>
8//!   git remote add origin htree://<petname>/<repo-name>
9//!   git push origin main
10//!   git pull origin main
11
12use anyhow::{bail, Context, Result};
13use std::io::{BufRead, Write};
14use tracing::{debug, info, warn};
15
16mod git;
17mod helper;
18mod nostr_client;
19
20use hashtree_config::Config;
21use helper::RemoteHelper;
22use nostr_client::resolve_identity;
23
24/// Entry point for the git remote helper
25/// Call this from main() to run the helper
26pub fn main_entry() {
27    // Install TLS crypto provider for reqwest/rustls
28    let _ = rustls::crypto::ring::default_provider().install_default();
29
30    if let Err(e) = run() {
31        eprintln!("Error: {:#}", e);
32        std::process::exit(1);
33    }
34}
35
36fn run() -> Result<()> {
37    // Suppress broken pipe panics - git may close the pipe early
38    #[cfg(unix)]
39    {
40        unsafe {
41            libc::signal(libc::SIGPIPE, libc::SIG_DFL);
42        }
43    }
44
45    // Initialize logging
46    tracing_subscriber::fmt()
47        .with_env_filter(
48            tracing_subscriber::EnvFilter::from_default_env()
49                .add_directive("git_remote_htree=info".parse().unwrap()),
50        )
51        .with_writer(std::io::stderr)
52        .init();
53
54    let args: Vec<String> = std::env::args().collect();
55    debug!("git-remote-htree called with args: {:?}", args);
56
57    // Git calls us as: git-remote-htree <remote-name> <url>
58    if args.len() < 3 {
59        bail!("Usage: git-remote-htree <remote-name> <url>");
60    }
61
62    let remote_name = &args[1];
63    let url = &args[2];
64
65    info!("Remote: {}, URL: {}", remote_name, url);
66
67    // Parse URL: htree://<identifier>/<repo-name>
68    let (identifier, repo_name) = parse_htree_url(url)?;
69
70    // Resolve identifier to pubkey
71    let (pubkey, secret_key) = resolve_identity(&identifier)?;
72
73    if secret_key.is_some() {
74        debug!("Found signing key for {}", identifier);
75    } else {
76        warn!("No signing key for {} - push will fail", identifier);
77    }
78
79    // Load config
80    let config = Config::load_or_default();
81    debug!("Loaded config with {} read servers, {} write servers",
82           config.blossom.read_servers.len(),
83           config.blossom.write_servers.len());
84
85    // Create helper and run protocol
86    let mut helper = RemoteHelper::new(&pubkey, &repo_name, secret_key, config)?;
87
88    // Read commands from stdin, write responses to stdout
89    let stdin = std::io::stdin();
90    let stdout = std::io::stdout();
91    let mut stdout = stdout.lock();
92
93    for line in stdin.lock().lines() {
94        let line = match line {
95            Ok(l) => l,
96            Err(e) => {
97                if e.kind() == std::io::ErrorKind::BrokenPipe {
98                    break;
99                }
100                return Err(e.into());
101            }
102        };
103
104        debug!("< {}", line);
105
106        match helper.handle_command(&line) {
107            Ok(Some(responses)) => {
108                for response in responses {
109                    debug!("> {}", response);
110                    if let Err(e) = writeln!(stdout, "{}", response) {
111                        if e.kind() == std::io::ErrorKind::BrokenPipe {
112                            break;
113                        }
114                        return Err(e.into());
115                    }
116                }
117                if let Err(e) = stdout.flush() {
118                    if e.kind() == std::io::ErrorKind::BrokenPipe {
119                        break;
120                    }
121                    return Err(e.into());
122                }
123            }
124            Ok(None) => {}
125            Err(e) => {
126                warn!("Command error: {}", e);
127            }
128        }
129
130        if helper.should_exit() {
131            break;
132        }
133    }
134
135    Ok(())
136}
137
138/// Parse htree:// URL into (identifier, repo_name)
139fn parse_htree_url(url: &str) -> Result<(String, String)> {
140    let url = url
141        .strip_prefix("htree://")
142        .context("URL must start with htree://")?;
143
144    // Split on first /
145    let (identifier, repo) = url
146        .split_once('/')
147        .context("URL must be htree://<identifier>/<repo>")?;
148
149    // Handle repo paths like "repo/subpath" - just take the first component as repo name
150    let repo_name = repo.split('/').next().unwrap_or(repo);
151
152    if identifier.is_empty() {
153        bail!("Identifier cannot be empty");
154    }
155    if repo_name.is_empty() {
156        bail!("Repository name cannot be empty");
157    }
158
159    Ok((identifier.to_string(), repo_name.to_string()))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_parse_htree_url_pubkey() {
168        let (id, repo) = parse_htree_url(
169            "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
170        )
171        .unwrap();
172        assert_eq!(
173            id,
174            "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
175        );
176        assert_eq!(repo, "myrepo");
177    }
178
179    #[test]
180    fn test_parse_htree_url_npub() {
181        let (id, repo) =
182            parse_htree_url("htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test")
183                .unwrap();
184        assert!(id.starts_with("npub1"));
185        assert_eq!(repo, "test");
186    }
187
188    #[test]
189    fn test_parse_htree_url_petname() {
190        let (id, repo) = parse_htree_url("htree://alice/project").unwrap();
191        assert_eq!(id, "alice");
192        assert_eq!(repo, "project");
193    }
194
195    #[test]
196    fn test_parse_htree_url_with_subpath() {
197        let (id, repo) = parse_htree_url("htree://test/repo/some/path").unwrap();
198        assert_eq!(id, "test");
199        assert_eq!(repo, "repo");
200    }
201
202    #[test]
203    fn test_parse_htree_url_invalid_scheme() {
204        assert!(parse_htree_url("https://example.com/repo").is_err());
205    }
206
207    #[test]
208    fn test_parse_htree_url_no_repo() {
209        assert!(parse_htree_url("htree://pubkey").is_err());
210    }
211
212    #[test]
213    fn test_parse_htree_url_empty_identifier() {
214        assert!(parse_htree_url("htree:///repo").is_err());
215    }
216
217    #[test]
218    fn test_parse_htree_url_colon() {
219        // Some git versions may pass URL with : instead of /
220        let result = parse_htree_url("htree://test:repo");
221        assert!(result.is_err()); // We don't support : syntax
222    }
223}