Skip to main content

cli/cli/commands/remote/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote operations (push, pull, remote management).
3
4#[cfg(feature = "client")]
5use std::net::SocketAddr;
6
7use anyhow::{Context, Result};
8#[cfg(feature = "client")]
9use proto::AuthToken;
10use refs::Head;
11use repo::{Repository, RepositoryCapability};
12
13use super::snapshot::ensure_current_state;
14#[cfg(feature = "client")]
15use crate::client::HostedGrpcClient;
16use crate::{
17    bridge::GitBridge,
18    cli::{Cli, should_output_json, style},
19    client::LocalSync,
20    config::UserConfig,
21    remote::{RemoteTarget, resolve_remote_with_key},
22};
23
24mod remote_ops;
25
26pub use remote_ops::{cmd_pull, cmd_remote};
27
28/// Execute push command.
29pub async fn cmd_push(
30    cli: &Cli,
31    remote: Option<String>,
32    thread: Option<String>,
33    state: Option<String>,
34    force: bool,
35) -> Result<()> {
36    let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
37    if repo.capability() == RepositoryCapability::GitOverlay && !repo.hosted_enabled() {
38        let remote_name = remote.as_deref().unwrap_or("origin");
39        let mut bridge = GitBridge::new(&repo);
40        bridge.push(remote_name)?;
41        if should_output_json(cli, Some(repo.config())) {
42            println!(
43                "{{\"pushed\":true,\"transport\":\"git\",\"remote\":{:?}}}",
44                remote_name
45            );
46        } else {
47            println!(
48                "{} pushed Git-overlay refs to {}",
49                style::ok_marker(),
50                style::bold(remote_name)
51            );
52        }
53        return Ok(());
54    }
55
56    // `pre_push` JSON-protocol hook. Veto via non-empty
57    // `abort` aborts the push before any remote round-trip.
58    let hook_manager = repo::HookManager::new(&repo);
59    let hook_ctx = repo::HookContext::new(&repo);
60    let pre_push_payload = serde_json::json!({
61        "remote": remote.clone().unwrap_or_default(),
62    });
63    if let Ok(Some(resp)) = hook_manager.run_with_payload(
64        repo::Hook::PrePush,
65        &hook_ctx,
66        &pre_push_payload,
67        std::time::Duration::from_secs(5),
68    ) && !resp.abort.is_empty()
69    {
70        anyhow::bail!("pre_push hook vetoed: {}", resp.abort);
71    }
72
73    let state_id = if let Some(state_str) = state {
74        if matches!(state_str.as_str(), "HEAD" | "@") && repo.current_state()?.is_none() {
75            ensure_current_state(
76                &repo,
77                &UserConfig::load_default().unwrap_or_default(),
78                Some("Bootstrap git-overlay before push".to_string()),
79            )?;
80        }
81        repo.resolve_state(&state_str)?.context("State not found")?
82    } else {
83        ensure_current_state(
84            &repo,
85            &UserConfig::load_default().unwrap_or_default(),
86            Some("Bootstrap git-overlay before push".to_string()),
87        )?
88    };
89
90    let user_config = UserConfig::load_default().unwrap_or_default();
91    #[cfg(feature = "client")]
92    let mut token = user_config.remote_token();
93    #[cfg(not(feature = "client"))]
94    let token = user_config.remote_token();
95    #[cfg(feature = "client")]
96    let (target, server_key) =
97        resolve_remote_with_key(&repo, remote.as_deref()).map_err(anyhow::Error::msg)?;
98    #[cfg(not(feature = "client"))]
99    let (target, _server_key) =
100        resolve_remote_with_key(&repo, remote.as_deref()).map_err(anyhow::Error::msg)?;
101
102    // Fall back to the credential store if no token was provided via env/config.
103    #[cfg(feature = "client")]
104    let mut credential_proof_key: Option<String> = None;
105    #[cfg(feature = "client")]
106    if token.is_none()
107        && let Some(ref key) = server_key
108        && let Ok(Some(cred)) = heddle_client::credentials::resolve_credential_for_server(key)
109    {
110        token = Some(AuthToken::new(cred.token, "credential-store"));
111        credential_proof_key = cred.private_key_pem;
112    }
113
114    let track_name = resolve_default_push_thread(&repo, thread.as_deref())?;
115
116    match target {
117        RemoteTarget::Local(path) => {
118            push_local(&repo, &path, &state_id, &track_name, force, cli).await?;
119        }
120        RemoteTarget::Network { addr, repo_path } => {
121            #[cfg(feature = "client")]
122            push_network(
123                &repo,
124                PushNetworkOptions {
125                    addr,
126                    repo_path: repo_path.as_deref(),
127                    user_config: &user_config,
128                    token,
129                    server_key,
130                    credential_proof_key,
131                    state_id: &state_id,
132                    track_name: &track_name,
133                    force,
134                    cli,
135                },
136            )
137            .await?;
138            #[cfg(not(feature = "client"))]
139            let _ = (addr, repo_path, token);
140            #[cfg(not(feature = "client"))]
141            anyhow::bail!(
142                "network push support is not available in this build; enable the `client` feature"
143            );
144        }
145    }
146
147    // `post_push` JSON-protocol hook. Best-effort; fires after
148    // a successful push.
149    let post_push_payload = serde_json::json!({
150        "remote": remote.unwrap_or_default(),
151    });
152    if let Err(err) = hook_manager.run_with_payload(
153        repo::Hook::PostPush,
154        &hook_ctx,
155        &post_push_payload,
156        std::time::Duration::from_secs(5),
157    ) {
158        tracing::warn!(error = %err, "post_push hook error swallowed");
159    }
160
161    Ok(())
162}
163
164fn resolve_default_push_thread(repo: &Repository, requested: Option<&str>) -> Result<String> {
165    if let Some(requested) = requested {
166        return Ok(requested.to_string());
167    }
168
169    match repo.head_ref()? {
170        Head::Attached { thread } => Ok(thread),
171        Head::Detached { .. } => Ok("main".to_string()),
172    }
173}
174
175async fn push_local(
176    repo: &Repository,
177    target_path: &std::path::Path,
178    state_id: &objects::object::ChangeId,
179    track_name: &str,
180    _force: bool,
181    cli: &Cli,
182) -> Result<()> {
183    if should_output_json(cli, Some(repo.config())) {
184        println!("{{\"status\":\"connected\",\"type\":\"local\"}}");
185    } else {
186        println!(
187            "{} pushing to {}",
188            style::working_marker(),
189            style::dim(&format!("file://{}", target_path.display()))
190        );
191    }
192
193    let target_repo = Repository::open(target_path)?;
194
195    let sync = LocalSync::open(repo.root())?;
196    let objects_copied = sync.fetch_state(&target_repo, state_id)?;
197
198    target_repo.refs().set_thread(track_name, state_id)?;
199
200    if should_output_json(cli, Some(repo.config())) {
201        println!(
202            "{{\"success\":true,\"state\":\"{}\",\"objects\":{}}}",
203            state_id, objects_copied
204        );
205    } else {
206        println!(
207            "{} pushed {} to {} ({})",
208            style::ok_marker(),
209            style::change_id(&state_id.short().to_string()),
210            style::bold(track_name),
211            style::count(objects_copied, "object")
212        );
213    }
214
215    Ok(())
216}
217
218#[cfg(feature = "client")]
219async fn push_network(repo: &Repository, options: PushNetworkOptions<'_>) -> Result<()> {
220    let repo_path = options
221        .repo_path
222        .context("network remotes must include a hosted repository path")?;
223
224    let mut config = options.user_config.heddle_client_config(options.token);
225    if let Some(key) = options.server_key {
226        config = config.with_server_key(key);
227    }
228    if let Some(pem) = options.credential_proof_key
229        && config.auth_proof_key_pem.is_none()
230    {
231        config = config.with_auth_proof_key_pem(pem);
232    }
233    let mut client = HostedGrpcClient::connect(options.addr, &config).await?;
234    client.auto_rotate_if_needed().await;
235
236    if should_output_json(options.cli, Some(repo.config())) {
237        println!("{{\"status\":\"connected\"}}");
238    } else {
239        println!(
240            "{} connected to {}",
241            style::ok_marker(),
242            style::dim(&options.addr.to_string())
243        );
244    }
245
246    let result = client
247        .push(
248            repo,
249            repo_path,
250            *options.state_id,
251            options.track_name,
252            options.force,
253        )
254        .await?;
255
256    if result.success {
257        if should_output_json(options.cli, Some(repo.config())) {
258            println!(
259                "{{\"success\":true,\"state\":\"{}\"}}",
260                result.new_state.map(|s| s.to_string()).unwrap_or_default()
261            );
262        } else {
263            println!(
264                "{} pushed to {}",
265                style::ok_marker(),
266                style::bold(options.track_name)
267            );
268            if let Some(new_state) = result.new_state {
269                println!(
270                    "{}",
271                    style::field("remote state", &style::change_id(&new_state.to_string()))
272                );
273            }
274        }
275    } else {
276        let err = result.error.unwrap_or_else(|| "Unknown error".to_string());
277        return Err(anyhow::anyhow!("Push failed: {}", err));
278    }
279
280    Ok(())
281}
282
283#[cfg(feature = "client")]
284struct PushNetworkOptions<'a> {
285    addr: SocketAddr,
286    repo_path: Option<&'a str>,
287    user_config: &'a UserConfig,
288    token: Option<AuthToken>,
289    server_key: Option<String>,
290    credential_proof_key: Option<String>,
291    state_id: &'a objects::object::ChangeId,
292    track_name: &'a str,
293    force: bool,
294    cli: &'a Cli,
295}