1use clap::Args;
6
7use crate::CliError;
8use crate::output::ExitKind;
9use crate::setup::{CliContext, CliEngine};
10
11#[derive(Args, Debug)]
13pub struct FetchArgs {
14 pub mem: String,
15 #[arg(long, default_value = "origin")]
16 pub remote: String,
17 #[arg(num_args = 0..)]
20 pub refspecs: Vec<String>,
21}
22
23#[derive(Args, Debug)]
25pub struct PullArgs {
26 pub mem: String,
27 #[arg(long, default_value = "origin")]
28 pub remote: String,
29}
30
31#[derive(Args, Debug)]
34pub struct PushArgs {
35 #[arg(required_unless_present = "all", conflicts_with = "all")]
37 pub mem: Option<String>,
38 #[arg(long, default_value = "origin")]
39 pub remote: String,
40 #[arg(long, default_value_t = false, conflicts_with = "all")]
45 pub force: bool,
46 #[arg(long, default_value_t = false)]
54 pub all: bool,
55}
56
57pub fn run_fetch(ctx: &CliContext, args: FetchArgs) -> anyhow::Result<()> {
58 let outcome = match ctx.cli_engine()? {
59 CliEngine::MemRepo(engine) => engine
60 .fetch(&args.mem, &args.remote, &args.refspecs)
61 .map_err(CliError::from_engine_op)?,
62 CliEngine::Filesystem(_) => return Err(folder_refusal("memstead fetch", &args.mem)),
63 };
64 if ctx.json {
65 crate::output::print_json(&outcome)?;
66 } else {
67 let updated = if outcome.updated_refs.is_empty() {
68 " (no refs changed)".to_string()
69 } else {
70 outcome
71 .updated_refs
72 .iter()
73 .map(|u| {
74 let prev = if u.previous_sha.is_empty() {
75 "<new>".to_string()
76 } else {
77 u.previous_sha.clone()
78 };
79 format!(" - {} : {prev} -> {}", u.ref_name, u.new_sha)
80 })
81 .collect::<Vec<_>>()
82 .join("\n")
83 };
84 crate::output::print_markdown(&format!(
85 "# Fetched from `{}`\n\n- Refspecs: {}\n- Updated refs:\n{}",
86 outcome.remote,
87 if outcome.refspecs.is_empty() {
88 "<defaults>".to_string()
89 } else {
90 outcome.refspecs.join(", ")
91 },
92 updated,
93 ));
94 }
95 Ok(())
96}
97
98pub fn run_pull(ctx: &CliContext, args: PullArgs) -> anyhow::Result<()> {
99 let outcome = match ctx.cli_engine()? {
100 CliEngine::MemRepo(mut engine) => engine
101 .pull(&args.mem, &args.remote)
102 .map_err(CliError::from_engine_op)?,
103 CliEngine::Filesystem(_) => return Err(folder_refusal("memstead pull", &args.mem)),
104 };
105 if ctx.json {
106 crate::output::print_json(&outcome)?;
107 } else {
108 let prev = if outcome.previous_sha.is_empty() {
109 "<new branch>".to_string()
110 } else {
111 outcome.previous_sha.clone()
112 };
113 crate::output::print_markdown(&format!(
114 "# Pulled `{}`\n\n- Branch ref: `{}`\n- Source ref: `{}`\n- Previous: `{prev}`\n- New: `{}`",
115 outcome.mem, outcome.branch_ref, outcome.source_ref, outcome.new_sha,
116 ));
117 }
118 Ok(())
119}
120
121pub fn run_push(ctx: &CliContext, args: PushArgs) -> anyhow::Result<()> {
122 if args.all {
123 return run_push_all(ctx, &args.remote);
124 }
125 let mem = args.mem.as_deref().unwrap_or_default();
127 let outcome = match ctx.cli_engine()? {
128 CliEngine::MemRepo(engine) => engine
129 .push(mem, &args.remote, args.force)
130 .map_err(CliError::from_engine_op)?,
131 CliEngine::Filesystem(_) => return Err(folder_refusal("memstead push", mem)),
132 };
133 if ctx.json {
134 crate::output::print_json(&outcome)?;
135 } else {
136 let force_note = if outcome.forced { " (forced)" } else { "" };
137 crate::output::print_markdown(&format!(
138 "# Pushed `{}` to `{}`{force_note}\n\n- Branch ref: `{}`\n- New SHA at remote: `{}`",
139 outcome.mem, outcome.remote, outcome.branch_ref, outcome.new_sha,
140 ));
141 }
142 Ok(())
143}
144
145fn run_push_all(ctx: &CliContext, remote: &str) -> anyhow::Result<()> {
152 let outcome = match ctx.cli_engine()? {
153 CliEngine::MemRepo(engine) => engine.push_all(remote).map_err(CliError::from_engine_op)?,
154 CliEngine::Filesystem(_) => {
155 return Err(CliError {
156 code: "INVALID_INPUT",
157 kind: ExitKind::Validation,
158 message: "this workspace has no git-branch mems — `memstead push --all` \
159 requires a mem-repo workspace"
160 .to_string(),
161 details: None,
162 }
163 .into());
164 }
165 };
166 if ctx.json {
167 if outcome.refused.is_empty() {
171 crate::output::print_json(&outcome)?;
172 }
173 } else {
174 for p in &outcome.pushed {
175 let prev = if p.previous_sha.is_empty() {
176 "<new>".to_string()
177 } else {
178 p.previous_sha.clone()
179 };
180 println!("{} {prev} -> {}", p.ref_name, p.new_sha);
181 }
182 }
183 if let Some(first) = outcome.refused.first() {
184 let code: &'static str = match first.code.as_str() {
185 "NON_FAST_FORWARD" => "NON_FAST_FORWARD",
186 "LOCAL_INVALID_STATE" => "LOCAL_INVALID_STATE",
187 "UNKNOWN_REF" => "UNKNOWN_REF",
188 "UNKNOWN_REMOTE" => "UNKNOWN_REMOTE",
189 _ => "INTERNAL",
190 };
191 let listed = outcome
192 .refused
193 .iter()
194 .map(|r| {
195 format!(
196 "{} ({}{})",
197 r.ref_name,
198 r.code,
199 r.mem
200 .as_deref()
201 .map(|m| format!(", mem `{m}`"))
202 .unwrap_or_default()
203 )
204 })
205 .collect::<Vec<_>>()
206 .join(", ");
207 return Err(CliError {
208 code,
209 kind: ExitKind::Validation,
210 message: format!(
211 "memstead push --all: {} ref(s) refused, {} pushed, {} already in sync — refused: {listed}. \
212 A NON_FAST_FORWARD ref has commits on the remote this clone lacks: \
213 `memstead fetch <mem>` then `memstead pull <mem>` for that mem, then run `memstead push --all` again.",
214 outcome.refused.len(),
215 outcome.pushed.len(),
216 outcome.in_sync.len(),
217 ),
218 details: Some(serde_json::json!({
219 "remote": outcome.remote,
220 "refused": outcome.refused,
221 "pushed": outcome.pushed,
222 "in_sync": outcome.in_sync,
223 })),
224 }
225 .into());
226 }
227 Ok(())
228}
229
230fn folder_refusal(op: &str, mem: &str) -> anyhow::Error {
231 CliError {
232 code: "INVALID_INPUT",
233 kind: ExitKind::Validation,
234 message: format!("mem '{mem}' is not git-backed — `{op}` requires a git-branch mount",),
235 details: None,
236 }
237 .into()
238}