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                // Exit on error to avoid hanging
150                return Err(e);
151            }
152        }
153
154        if helper.should_exit() {
155            break;
156        }
157    }
158
159    Ok(())
160}
161
162/// Parse htree:// URL into (identifier, repo_name)
163fn parse_htree_url(url: &str) -> Result<(String, String)> {
164    let url = url
165        .strip_prefix("htree://")
166        .context("URL must start with htree://")?;
167
168    // Split on first /
169    let (identifier, repo) = url
170        .split_once('/')
171        .context("URL must be htree://<identifier>/<repo>")?;
172
173    // Handle repo paths like "repo/subpath" - just take the first component as repo name
174    let repo_name = repo.split('/').next().unwrap_or(repo);
175
176    if identifier.is_empty() {
177        bail!("Identifier cannot be empty");
178    }
179    if repo_name.is_empty() {
180        bail!("Repository name cannot be empty");
181    }
182
183    Ok((identifier.to_string(), repo_name.to_string()))
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_parse_htree_url_pubkey() {
192        let (id, repo) = parse_htree_url(
193            "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
194        )
195        .unwrap();
196        assert_eq!(
197            id,
198            "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
199        );
200        assert_eq!(repo, "myrepo");
201    }
202
203    #[test]
204    fn test_parse_htree_url_npub() {
205        let (id, repo) =
206            parse_htree_url("htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test")
207                .unwrap();
208        assert!(id.starts_with("npub1"));
209        assert_eq!(repo, "test");
210    }
211
212    #[test]
213    fn test_parse_htree_url_petname() {
214        let (id, repo) = parse_htree_url("htree://alice/project").unwrap();
215        assert_eq!(id, "alice");
216        assert_eq!(repo, "project");
217    }
218
219    #[test]
220    fn test_parse_htree_url_self() {
221        let (id, repo) = parse_htree_url("htree://self/myrepo").unwrap();
222        assert_eq!(id, "self");
223        assert_eq!(repo, "myrepo");
224    }
225
226    #[test]
227    fn test_parse_htree_url_with_subpath() {
228        let (id, repo) = parse_htree_url("htree://test/repo/some/path").unwrap();
229        assert_eq!(id, "test");
230        assert_eq!(repo, "repo");
231    }
232
233    #[test]
234    fn test_parse_htree_url_invalid_scheme() {
235        assert!(parse_htree_url("https://example.com/repo").is_err());
236    }
237
238    #[test]
239    fn test_parse_htree_url_no_repo() {
240        assert!(parse_htree_url("htree://pubkey").is_err());
241    }
242
243    #[test]
244    fn test_parse_htree_url_empty_identifier() {
245        assert!(parse_htree_url("htree:///repo").is_err());
246    }
247
248    #[test]
249    fn test_parse_htree_url_colon() {
250        // Some git versions may pass URL with : instead of /
251        let result = parse_htree_url("htree://test:repo");
252        assert!(result.is_err()); // We don't support : syntax
253    }
254}