1#[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
28pub 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 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 #[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 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}