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