1use super::capture_invocation::{InvocationCapture, base_revision, capture_invocation_base, capture_invocation_layout};
19use super::capture_order::{CaptureIndexes, indexed_request};
20use super::commit_convergence::{CommitConvergenceInput, converge_commit_policies};
21use super::commit_facts::PrivateIndexFacts;
22use super::commit_index::{IndexSelection, initialize_commit_index, list_changed_index_paths, list_unmerged_index_paths};
23use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, base_object, decide_selection, failed, private_checks, staged_at_base};
24use super::lfs_pre_push::uses_lfs;
25use super::policy_checks::ShippedChecks;
26use super::policy_engine::{StageRequest, pass_exit_code};
27use super::policy_events::PolicyEvent;
28use super::private_index_stage::PrivateStage;
29use super::private_storage::{create_private_directory, remove_tree};
30use super::random_id::random_uuid;
31use super::repository_facts::RepositoryFacts;
32use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT, Submitted, submit};
33use super::diagnostic_log::warn;
34use super::service_commit_args::{ServiceCommitArgs, opens_editor, parse_service_commit};
35use super::service_native::{BuildInput, GitBuilt, build_with_git};
36use super::service_amend::{AmendSource, amended_author, read_amend_source};
37use super::service_message::{author_overrides, build_message, identity};
38use super::service_land::LANDING_ATTEMPTS;
39use super::service_messages::{ClientFailure, render};
40use super::service_protocol::{ALLOWED_MODES, Change, CommitRequest, MAX_CHANGES, is_object_name, is_plain_path};
41use super::service_handoff::ServiceHandoff;
42use super::service_provision::provision;
43use super::service_route::{Route, RouteInputs, choose_route, remembered_own_executable};
44use super::config_snapshot::ConfigSnapshot;
45use super::transaction_git::{GitContext, run_git_checked};
46use super::transaction_journal::{Base, Conclusion, SymbolicHead, TransactionMode};
47use std::ffi::OsString;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50
51pub struct ServiceCommit {
53 pub result: TransactionResult,
55 pub handoff: Option<ServiceHandoff>,
57}
58
59pub const REBUILD_ATTEMPTS: usize = 5;
62
63struct Scratch(PathBuf);
65
66impl Drop for Scratch {
67 fn drop(&mut self) {
68 let _removed = remove_tree(self.0.as_path());
69 }
70}
71
72enum Prepared {
74 Decline,
76 Blocked(TransactionResult),
78 Ready(Box<Ready>),
80}
81
82struct Ready {
83 repository: PathBuf,
84 common_dir: PathBuf,
85 reference: String,
86 base: String,
87 changes: Vec<Change>,
88 events: Vec<PolicyEvent>,
89 uses_lfs: bool,
90 scratch: Scratch,
92 needs_native: bool,
94 inputs: NativeInputs,
96}
97
98struct NativeInputs {
100 capture: InvocationCapture,
101 arguments: Vec<OsString>,
102 pathspecs: Vec<String>,
103 mode: TransactionMode,
104 staged_all: bool,
105 commit_index: PathBuf,
106}
107
108fn changes_of(context: &GitContext, cwd: &Path, indexes: &CaptureIndexes, base: &str) -> Result<Option<Vec<Change>>, TransactionStop> {
110 let arguments: Vec<String> = ["diff-index", "--cached", "-r", "--raw", "-z", "--no-renames", "--no-abbrev", base].map(String::from).to_vec();
111 let output = run_git_checked(
112 context,
113 &indexed_request(cwd, &arguments, Some(indexes.commit_index.as_path()), Some(indexes.object_directory.as_path())),
114 )?;
115 let text: String = match String::from_utf8(output.stdout) {
116 Ok(text) => text,
117 Err(_) => return Ok(None),
118 };
119 let mut fields = text.split('\0').filter(|field| { return !field.is_empty(); });
120 let mut changes: Vec<Change> = Vec::new();
121 while let Some(header) = fields.next() {
122 let Some(path) = fields.next() else { return Ok(None) };
123 let parts: Vec<&str> = header.trim_start_matches(':').split(' ').collect();
124 let [_, new_mode, _, new_name, status] = parts.as_slice() else { return Ok(None) };
125 let deletion: bool = status.starts_with('D');
126 let mode: u32 = if deletion { ALLOWED_MODES[0] } else { u32::from_str_radix(new_mode, 8).unwrap_or(0) };
127 if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || (!deletion && !is_object_name(new_name)) {
128 return Ok(None);
129 }
130 changes.push(Change { path: path.to_string(), mode, blob: if deletion { None } else { Some((*new_name).to_string()) } });
131 }
132 return Ok(if changes.len() > MAX_CHANGES { None } else { Some(changes) });
133}
134
135fn prepare<F: RepositoryFacts>(
137 invocation: &CommitInvocation<'_>,
138 checks: &mut ShippedChecks<F>,
139 request: &StageRequest,
140 scratch_name: &str,
141 allow_empty: bool,
142) -> Result<Prepared, TransactionStop> {
143 let context: &GitContext = invocation.context;
144 let cwd: &Path = invocation.cwd;
145 let layout = capture_invocation_layout(context, cwd, false)?;
146 if layout.conclusion != Conclusion::None {
147 return Ok(Prepared::Decline);
148 }
149 let selection = decide_selection(invocation.region, layout.conclusion);
150 let SymbolicHead::Branch(reference) = layout.symbolic_head.clone() else { return Ok(Prepared::Decline) };
151 let named: bool = selection.mode == TransactionMode::ExplicitPath;
154 if selection.read_only || (selection.mode == TransactionMode::Index && selection.selects_paths) {
155 return Ok(Prepared::Decline);
156 }
157 let capture: InvocationCapture = capture_invocation_base(context, cwd, layout)?;
158 if !matches!(capture.base, Base::Commit(_)) {
159 return Ok(Prepared::Decline);
160 }
161 let needs_native: bool = config_needs_native(capture.layout.config_snapshot.as_ref());
164 let amends: bool = invocation.region.amend;
166 let base: String = base_revision(&capture);
167 let scratch: Scratch = Scratch(capture.layout.git_dir.join(scratch_name));
168 create_private_directory(scratch.0.as_path()).map_err(|error| { return failed(format!("preparing the commit failed: {error}")); })?;
169 let indexes: CaptureIndexes = CaptureIndexes {
170 captured_index: scratch.0.join("captured.index"),
171 commit_index: scratch.0.join("commit.index"),
172 object_directory: capture.layout.object_directory.clone(),
173 };
174 let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
175 let pathspecs: Vec<OsString> = invocation.region.pathspecs.iter().map(|at| { return region_arguments[*at].clone(); }).collect();
176 let index_selection: IndexSelection<'_> = IndexSelection {
177 mode: selection.mode,
178 pathspecs: if named { pathspecs.as_slice() } else { &[] },
179 pathspec_file: None,
180 pathspec_file_nul: false,
181 stage_into_index: false,
182 stage_tracked_changes: selection.staged_all,
183 base_revision: base.as_str(),
184 };
185 initialize_commit_index(context, cwd, capture.layout.real_index_path.as_path(), &indexes, &index_selection)
186 .map_err(|error| { return failed(error.0); })?;
187 if !list_unmerged_index_paths(context, cwd, indexes.commit_index.as_path())?.is_empty() {
188 return Ok(Prepared::Decline);
189 }
190 let candidate_paths: Vec<Vec<u8>> =
191 list_changed_index_paths(context, cwd, indexes.commit_index.as_path(), base.as_str(), indexes.object_directory.as_path())?;
192 if candidate_paths.is_empty() && !allow_empty && !amends {
193 return Ok(Prepared::Decline);
194 }
195 let mut private: ShippedChecks<PrivateIndexFacts<&mut F>> = private_checks(
196 checks,
197 indexes.commit_index.as_path(),
198 indexes.object_directory.as_path(),
199 staged_at_base(base_object(&capture)?, candidate_paths.as_slice()),
200 );
201 let outcome = converge_commit_policies(CommitConvergenceInput {
202 request,
203 checks: &mut private,
204 stage: PrivateStage {
205 context,
206 cwd,
207 index: indexes.commit_index.as_path(),
208 object_directory: Some(indexes.object_directory.as_path()),
209 },
210 });
211 let code: i32 = pass_exit_code(outcome.pass.events.as_slice(), outcome.pass.end);
212 if code != 0 {
213 return Ok(Prepared::Blocked(TransactionResult { events: outcome.pass.events, exit_code: code, landed_oid: None, uses_lfs: false }));
214 }
215 if !outcome.changed_paths.is_empty() {
216 return Ok(Prepared::Decline);
218 }
219 let Some(changes) = changes_of(context, cwd, &indexes, base.as_str())? else { return Ok(Prepared::Decline) };
220 if changes.is_empty() && !allow_empty && !amends {
221 return Ok(Prepared::Decline);
222 }
223 return Ok(Prepared::Ready(Box::new(Ready {
224 repository: capture.layout.repository_root.clone(),
225 common_dir: capture.layout.common_dir.clone(),
226 reference,
227 base,
228 changes,
229 events: outcome.pass.events,
230 uses_lfs: capture.layout.config_snapshot.as_ref().is_none_or(uses_lfs),
231 needs_native,
232 inputs: NativeInputs {
233 arguments: outcome.pass.arguments.clone(),
234 pathspecs: pathspecs.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect(),
235 mode: selection.mode,
236 staged_all: selection.staged_all,
237 commit_index: indexes.commit_index.clone(),
238 capture,
239 },
240 scratch,
241 })));
242}
243
244fn config_needs_native(snapshot: Option<&ConfigSnapshot>) -> bool {
247 const KEYS: [&str; 7] = ["commit.template", "commit.cleanup", "i18n.commitencoding", "core.commentchar", "core.commentstring", "commit.signoff", "commit.verbose"];
248 let Some(found) = snapshot else { return true };
249 return found.entries().iter().any(|entry| {
250 let value: String = entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).to_lowercase(); }).unwrap_or_default();
251 let signs: bool = entry.key == "commit.gpgsign" && !["false", "no", "off", "0"].contains(&value.as_str());
252 return signs || KEYS.contains(&entry.key.as_str()) || entry.key.starts_with("trailer.");
253 });
254}
255
256pub fn try_service_commit<F: RepositoryFacts>(
266 invocation: &CommitInvocation<'_>,
267 checks: &mut ShippedChecks<F>,
268 request: &StageRequest,
269) -> Result<Option<ServiceCommit>, TransactionStop> {
270 let path_variable = |name: &str| -> Option<PathBuf> { return std::env::var_os(name).filter(|value| { return !value.is_empty(); }).map(PathBuf::from); };
271 let runtime: Option<PathBuf> = path_variable("XDG_RUNTIME_DIR");
272 let config_home: Option<PathBuf> = path_variable("XDG_CONFIG_HOME");
273 let home: Option<PathBuf> = path_variable("HOME");
274 let bus_address: Option<String> = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok();
275 let route: Route = choose_route(&RouteInputs {
276 runtime_dir: runtime.as_deref(),
277 bus_address: bus_address.as_deref(),
278 config_home: config_home.as_deref(),
279 home: home.as_deref(),
280 own_executable: remembered_own_executable(),
281 real_git: invocation.context.real_git.as_path(),
282 });
283 let Route::Service { address, provision: provisioning } = route else { return Ok(None) };
284 let region = invocation.region;
285 if region.include || region.interactive || region.patch || region.dry_run || region.pathspec_from_file.is_some() {
286 return Ok(None);
287 }
288 let after: &[OsString] = invocation.arguments.get(invocation.subcommand_index + 1..).unwrap_or(&[]);
289 let id: String = random_uuid().map_err(|error| { return failed(format!("an identity for this commit could not be made: {error:?}")); })?;
290 let allow_empty: bool = region.allow_empty;
291 let mut provisioned: bool = false;
292 let editor_possible: bool = opens_editor(after);
294 let config: ClientConfig = ClientConfig { address, activation: Activation::Managed, start_wait: START_WAIT, commit_wait: COMMIT_WAIT };
295 let scratch_name: String = format!("cli-git-service-{id}");
296 let mut embedded: Option<Embedded> = None;
300 let mut decided: bool = false; for attempt in 0..REBUILD_ATTEMPTS {
301 let ready: Box<Ready> = match prepare(invocation, checks, request, scratch_name.as_str(), allow_empty)? {
302 Prepared::Decline => return Ok(None),
303 Prepared::Blocked(result) => return Ok(Some(ServiceCommit { result, handoff: None })),
304 Prepared::Ready(ready) => ready,
305 };
306 if !decided {
307 decided = true;
308 if !ready.needs_native
309 && let Some(parsed) = parse_service_commit(after)
310 {
311 embedded = embedded_commit(invocation, parsed)?;
312 }
313 }
314 let native: bool = embedded.is_none();
315 let inputs: &NativeInputs = &ready.inputs;
316 let mut tree: Option<String> = None;
320 if let Some(source) = embedded.as_ref().and_then(|made| { return made.source.as_ref(); }) {
321 if source.base != ready.base {
322 let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
323 return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
324 }
325 let written = run_git_checked(
326 invocation.context,
327 &indexed_request(
328 invocation.cwd,
329 &["write-tree"],
330 Some(inputs.commit_index.as_path()),
331 Some(inputs.capture.layout.object_directory.as_path()),
332 ),
333 )?;
334 let made: String = String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string();
335 let against: &str = source.parent_tree.as_deref().unwrap_or(inputs.capture.layout.empty_tree_oid.as_str());
336 if source.parents.len() < 2 && !allow_empty && made == against {
337 return Ok(None);
338 }
339 tree = Some(made);
340 }
341 let mut typed: Option<String> = None;
342 let (sent, first_parent): (CommitRequest, String) = match embedded.as_ref() {
343 None => {
344 let built: GitBuilt = build_with_git(&BuildInput {
345 context: invocation.context,
346 cwd: invocation.cwd,
347 capture: &inputs.capture,
348 scratch: ready.scratch.0.as_path(),
349 commit_index: inputs.commit_index.as_path(),
350 arguments: inputs.arguments.as_slice(),
351 pathspecs: inputs.pathspecs.as_slice(),
352 mode: inputs.mode,
353 staged_all: inputs.staged_all,
354 quiet: attempt > 0,
355 })?;
356 let parent: String = built.parents.first().cloned().unwrap_or_default();
357 typed = Some(built.message.clone());
358 let sent: CommitRequest = CommitRequest {
359 id: id.clone(),
360 repository: ready.repository.clone(),
361 common_dir: ready.common_dir.clone(),
362 reference: ready.reference.clone(),
363 base: ready.base.clone(),
364 changes: ready.changes.clone(),
365 message: String::new(),
367 author: None,
368 committer: String::new(),
369 amend: invocation.region.amend,
370 tree: None,
371 commit: Some(built.commit),
372 };
373 (sent, parent)
374 }
375 Some(made) => {
376 let sent: CommitRequest = CommitRequest {
377 id: id.clone(),
378 repository: ready.repository.clone(),
379 common_dir: ready.common_dir.clone(),
380 reference: ready.reference.clone(),
381 base: ready.base.clone(),
382 changes: ready.changes.clone(),
383 message: made.message.clone(),
384 author: Some(made.author.clone()),
385 committer: made.committer.clone(),
386 amend: tree.is_some(),
387 tree: tree.clone(),
388 commit: None,
389 };
390 let parent: String = match made.source.as_ref() {
392 Some(replaced) => replaced.parents.first().cloned().unwrap_or_default(),
393 None => ready.base.clone(),
394 };
395 (sent, parent)
396 }
397 };
398 if let Some(host) = provisioning.as_ref().filter(|_| { return !provisioned; }) {
401 provision(host).map_err(|failure| { return TransactionStop::Message(render(&failure)); })?;
402 provisioned = true;
403 }
404 match submit(&config, &sent, &mut std::io::stderr()) {
405 Ok(Submitted::Landed { commit, .. }) => {
406 if let Some(made) = embedded.as_ref().filter(|made| { return !native && !made.quiet; }) {
407 let subject: &str = made.message.lines().next().unwrap_or("");
408 let short: &str = commit.get(..7).unwrap_or(commit.as_str());
409 let branch: &str = ready.reference.trim_start_matches("refs/heads/");
410 let _printed = writeln!(std::io::stdout(), "[{branch} {short}] {subject}");
411 }
412 if native && attempt > 0 {
413 let subject: &str = typed.as_deref().and_then(|text| { return text.lines().next(); }).unwrap_or("");
415 let short: &str = commit.get(..7).unwrap_or(commit.as_str());
416 let branch: &str = ready.reference.trim_start_matches("refs/heads/");
417 let _printed = writeln!(std::io::stdout(), "[{branch} {short}] {subject}");
418 }
419 let handoff: ServiceHandoff = ServiceHandoff {
420 config: config.clone(),
421 repository: ready.repository.clone(),
422 common_dir: ready.common_dir.clone(),
423 reference: ready.reference.clone(),
424 parent: first_parent,
425 };
426 let result: TransactionResult = TransactionResult { events: ready.events, exit_code: 0, landed_oid: Some(commit), uses_lfs: ready.uses_lfs };
427 return Ok(Some(ServiceCommit { result, handoff: Some(handoff) }));
428 }
429 Ok(Submitted::PrepareAgain { .. }) => {
430 if native || sent.amend {
431 let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
433 return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
434 }
435 if attempt + 1 == LANDING_ATTEMPTS {
436 let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
437 return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: LANDING_ATTEMPTS })));
438 }
439 }
440 Ok(Submitted::Moved) => {
441 let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
442 if native && !sent.amend && !editor_possible {
443 if attempt + 1 == REBUILD_ATTEMPTS {
445 return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: REBUILD_ATTEMPTS })));
446 }
447 continue;
448 }
449 if native && editor_possible && let Some(text) = typed.as_deref() {
450 let file: PathBuf = inputs.capture.layout.git_dir.join(format!("cli-git-saved-message-{}", id.get(..8).unwrap_or(id.as_str())));
452 match std::fs::write(&file, text) {
453 Ok(()) => {
454 let failure: ClientFailure = ClientFailure::BranchMovedMessageSaved { branch, file: file.display().to_string(), amend: sent.amend };
455 return Err(TransactionStop::Message(render(&failure)));
456 }
457 Err(error) => warn("serviceCommit", format!("saving the typed message failed: {error}").as_str()),
458 }
459 let again: String = ready.reference.trim_start_matches("refs/heads/").to_string();
460 return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch: again })));
461 }
462 return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
463 }
464 Err(failure) => return Err(TransactionStop::Message(render(&failure))),
465 }
466 }
467 return Ok(None);
468}
469
470struct Embedded {
472 message: String,
473 author: String,
474 committer: String,
475 source: Option<AmendSource>,
476 quiet: bool,
477}
478
479fn embedded_commit(invocation: &CommitInvocation<'_>, parsed: ServiceCommitArgs) -> Result<Option<Embedded>, TransactionStop> {
490 let Some(committer) = identity(invocation.context, invocation.cwd, "GIT_COMMITTER_IDENT", &[])? else { return Ok(None) };
491 let source: Option<AmendSource> = if parsed.amend {
492 let Some(read) = read_amend_source(invocation.context, invocation.cwd)? else { return Ok(None) };
493 Some(read)
494 } else {
495 None
496 };
497 let previous: Option<&str> = source.as_ref().map(|found| { return found.message.as_str(); });
498 let Some(built) = build_message(invocation, &parsed, committer.as_str(), previous)? else { return Ok(None) };
499 let author: String = match built.reused_author {
500 Some(reused) => reused,
501 None => match source.as_ref() {
502 Some(replaced) => {
503 let Some(made) = amended_author(invocation.context, invocation.cwd, replaced, &parsed)? else { return Ok(None) };
504 made
505 }
506 None => {
507 let Some(made) = identity(invocation.context, invocation.cwd, "GIT_AUTHOR_IDENT", author_overrides(&parsed).as_slice())? else {
508 return Ok(None);
509 };
510 made
511 }
512 },
513 };
514 return Ok(Some(Embedded { message: built.message, author, committer, source, quiet: parsed.quiet }));
515}
516
517#[cfg(test)]
519#[path = "service_commit_tests.rs"]
520mod tests;