Skip to main content

git_policy_cli/
service_commit.rs

1//! What:
2//!  The commit command's path through the resident service: prepare the commit in this process (private index,
3//!  policies), send the prepared change to the service, and report what it landed.
4//! Why:
5//!  The service is the only writer, so a commit takes no lock and polls nothing: preparation and policies run here,
6//!  outside the service's serialized step, and the service moves the branch once for a whole batch of commits
7//!  (`doc/planning/cli-git-resident-service.md`, "Where preparation and policies run").
8//!  This slice covers the plain scoped commit (`git commit -m <message> -- <paths>`); any other command line, a commit
9//!  in a merge, on an unborn branch or on a detached head, a policy that rewrites a file, and anything with nothing to
10//!  commit return `None` so native `git commit` handles them as before. It is on only when the person opts in.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const result = await tryServiceCommit(invocation, checks, request); // undefined when native git commit must run
15//! ```
16
17use super::capture_invocation::{InvocationCapture, base_revision, capture_invocation_base, capture_invocation_layout};
18use super::capture_order::{CaptureIndexes, indexed_request};
19use super::commit_convergence::{CommitConvergenceInput, converge_commit_policies};
20use super::commit_facts::PrivateIndexFacts;
21use super::commit_index::{IndexSelection, initialize_commit_index, list_changed_index_paths, list_unmerged_index_paths};
22use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, base_object, decide_selection, failed, private_checks, staged_at_base};
23use super::lfs_pre_push::uses_lfs;
24use super::policy_checks::ShippedChecks;
25use super::policy_engine::{StageRequest, pass_exit_code};
26use super::policy_events::PolicyEvent;
27use super::private_index_stage::PrivateStage;
28use super::private_storage::{create_private_directory, remove_tree};
29use super::random_id::random_uuid;
30use super::repository_facts::RepositoryFacts;
31use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT, Submitted, submit};
32use super::service_commit_args::parse_service_commit;
33use super::service_message::{author_overrides, build_message, identity};
34use super::service_land::LANDING_ATTEMPTS;
35use super::service_messages::{ClientFailure, render};
36use super::service_protocol::{ALLOWED_MODES, Change, CommitRequest, MAX_CHANGES, is_object_name, is_plain_path};
37use super::service_handoff::ServiceHandoff;
38use super::service_provision::provision;
39use super::service_route::{Route, RouteInputs, choose_route, remembered_own_executable};
40use super::config_snapshot::ConfigSnapshot;
41use super::transaction_git::{GitContext, run_git_checked};
42use super::transaction_journal::{Base, Conclusion, SymbolicHead, TransactionMode};
43use std::ffi::OsString;
44use std::io::Write;
45use std::path::{Path, PathBuf};
46
47/// What the service path returns: the transaction result for the lifecycle, and the handoff when the commit landed.
48pub struct ServiceCommit {
49    /// What the policies and the landing came to.
50    pub result: TransactionResult,
51    /// How to leave the after-commit work for the service; absent when nothing landed.
52    pub handoff: Option<ServiceHandoff>,
53}
54
55/// A scratch directory for the private indexes, removed when it goes out of scope.
56struct Scratch(PathBuf);
57
58impl Drop for Scratch {
59    fn drop(&mut self) {
60        let _removed = remove_tree(self.0.as_path());
61    }
62}
63
64/// What a preparation came to.
65enum Prepared {
66    /// Native `git commit` must handle this command.
67    Decline,
68    /// The policies stopped the commit.
69    Blocked(TransactionResult),
70    /// The change is ready to send.
71    Ready(Box<Ready>),
72}
73
74struct Ready {
75    repository: PathBuf,
76    common_dir: PathBuf,
77    reference: String,
78    base: String,
79    changes: Vec<Change>,
80    events: Vec<PolicyEvent>,
81    uses_lfs: bool,
82}
83
84/// The changes between the base and the private index: path, mode and object, deletions as no object.
85fn changes_of(context: &GitContext, cwd: &Path, indexes: &CaptureIndexes, base: &str) -> Result<Option<Vec<Change>>, TransactionStop> {
86    let arguments: Vec<String> = ["diff-index", "--cached", "-r", "--raw", "-z", "--no-renames", "--no-abbrev", base].map(String::from).to_vec();
87    let output = run_git_checked(
88        context,
89        &indexed_request(cwd, &arguments, Some(indexes.commit_index.as_path()), Some(indexes.object_directory.as_path())),
90    )?;
91    let text: String = match String::from_utf8(output.stdout) {
92        Ok(text) => text,
93        Err(_) => return Ok(None),
94    };
95    let mut fields = text.split('\0').filter(|field| { return !field.is_empty(); });
96    let mut changes: Vec<Change> = Vec::new();
97    while let Some(header) = fields.next() {
98        let Some(path) = fields.next() else { return Ok(None) };
99        let parts: Vec<&str> = header.trim_start_matches(':').split(' ').collect();
100        let [_, new_mode, _, new_name, status] = parts.as_slice() else { return Ok(None) };
101        let deletion: bool = status.starts_with('D');
102        let mode: u32 = if deletion { ALLOWED_MODES[0] } else { u32::from_str_radix(new_mode, 8).unwrap_or(0) };
103        if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || (!deletion && !is_object_name(new_name)) {
104            return Ok(None);
105        }
106        changes.push(Change { path: path.to_string(), mode, blob: if deletion { None } else { Some((*new_name).to_string()) } });
107    }
108    return Ok(if changes.len() > MAX_CHANGES { None } else { Some(changes) });
109}
110
111/// Prepares the commit once against the branch as it is now.
112fn prepare<F: RepositoryFacts>(
113    invocation: &CommitInvocation<'_>,
114    checks: &mut ShippedChecks<F>,
115    request: &StageRequest,
116    scratch_name: &str,
117    allow_empty: bool,
118) -> Result<Prepared, TransactionStop> {
119    let context: &GitContext = invocation.context;
120    let cwd: &Path = invocation.cwd;
121    let layout = capture_invocation_layout(context, cwd, false)?;
122    if layout.conclusion != Conclusion::None {
123        return Ok(Prepared::Decline);
124    }
125    let selection = decide_selection(invocation.region, layout.conclusion);
126    let SymbolicHead::Branch(reference) = layout.symbolic_head.clone() else { return Ok(Prepared::Decline) };
127    // An index commit takes the whole index (`-a` first stages every tracked change); a commit of named paths takes only
128    //  those. A mix of the two, or anything that stages by hand, is the transaction's.
129    let named: bool = selection.mode == TransactionMode::ExplicitPath;
130    if selection.read_only || (selection.mode == TransactionMode::Index && selection.selects_paths) {
131        return Ok(Prepared::Decline);
132    }
133    let capture: InvocationCapture = capture_invocation_base(context, cwd, layout)?;
134    if !matches!(capture.base, Base::Commit(_)) || config_needs_native(capture.layout.config_snapshot.as_ref()) {
135        return Ok(Prepared::Decline);
136    }
137    let base: String = base_revision(&capture);
138    let scratch: Scratch = Scratch(capture.layout.git_dir.join(scratch_name));
139    create_private_directory(scratch.0.as_path()).map_err(|error| { return failed(format!("preparing the commit failed: {error}")); })?;
140    let indexes: CaptureIndexes = CaptureIndexes {
141        captured_index: scratch.0.join("captured.index"),
142        commit_index: scratch.0.join("commit.index"),
143        object_directory: capture.layout.object_directory.clone(),
144    };
145    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
146    let pathspecs: Vec<OsString> = invocation.region.pathspecs.iter().map(|at| { return region_arguments[*at].clone(); }).collect();
147    let index_selection: IndexSelection<'_> = IndexSelection {
148        mode: selection.mode,
149        pathspecs: if named { pathspecs.as_slice() } else { &[] },
150        pathspec_file: None,
151        pathspec_file_nul: false,
152        stage_into_index: false,
153        stage_tracked_changes: selection.staged_all,
154        base_revision: base.as_str(),
155    };
156    initialize_commit_index(context, cwd, capture.layout.real_index_path.as_path(), &indexes, &index_selection)
157        .map_err(|error| { return failed(error.0); })?;
158    if !list_unmerged_index_paths(context, cwd, indexes.commit_index.as_path())?.is_empty() {
159        return Ok(Prepared::Decline);
160    }
161    let candidate_paths: Vec<Vec<u8>> =
162        list_changed_index_paths(context, cwd, indexes.commit_index.as_path(), base.as_str(), indexes.object_directory.as_path())?;
163    if candidate_paths.is_empty() && !allow_empty {
164        return Ok(Prepared::Decline);
165    }
166    let mut private: ShippedChecks<PrivateIndexFacts<&mut F>> = private_checks(
167        checks,
168        indexes.commit_index.as_path(),
169        indexes.object_directory.as_path(),
170        staged_at_base(base_object(&capture)?, candidate_paths.as_slice()),
171    );
172    let outcome = converge_commit_policies(CommitConvergenceInput {
173        request,
174        checks: &mut private,
175        stage: PrivateStage {
176            context,
177            cwd,
178            index: indexes.commit_index.as_path(),
179            object_directory: Some(indexes.object_directory.as_path()),
180        },
181    });
182    let code: i32 = pass_exit_code(outcome.pass.events.as_slice(), outcome.pass.end);
183    if code != 0 {
184        return Ok(Prepared::Blocked(TransactionResult { events: outcome.pass.events, exit_code: code, landed_oid: None, uses_lfs: false }));
185    }
186    if !outcome.changed_paths.is_empty() {
187        // A policy rewrote a file: the worktree copy of that rewrite is native work.
188        return Ok(Prepared::Decline);
189    }
190    let Some(changes) = changes_of(context, cwd, &indexes, base.as_str())? else { return Ok(Prepared::Decline) };
191    if changes.is_empty() && !allow_empty {
192        return Ok(Prepared::Decline);
193    }
194    return Ok(Prepared::Ready(Box::new(Ready {
195        repository: capture.layout.repository_root.clone(),
196        common_dir: capture.layout.common_dir.clone(),
197        reference,
198        base,
199        changes,
200        events: outcome.pass.events,
201        uses_lfs: capture.layout.config_snapshot.as_ref().is_none_or(uses_lfs),
202    })));
203}
204
205/// Whether the repository's configuration makes native `git commit` do something the service does not: sign, apply a
206///  template or a clean-up mode, use another message encoding or another comment character, or change trailers.
207fn config_needs_native(snapshot: Option<&ConfigSnapshot>) -> bool {
208    const KEYS: [&str; 7] = ["commit.template", "commit.cleanup", "i18n.commitencoding", "core.commentchar", "core.commentstring", "commit.signoff", "commit.verbose"];
209    let Some(found) = snapshot else { return true };
210    return found.entries().iter().any(|entry| {
211        let value: String = entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).to_lowercase(); }).unwrap_or_default();
212        let signs: bool = entry.key == "commit.gpgsign" && !["false", "no", "off", "0"].contains(&value.as_str());
213        return signs || KEYS.contains(&entry.key.as_str()) || entry.key.starts_with("trailer.");
214    });
215}
216
217/// What:
218///  Lands the commit through the service when the person opted in and the command line is one the service covers.
219/// Why:
220///   Returns `None` for everything else, before anything was changed, so native `git commit` runs unchanged.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// await tryServiceCommit(invocation, checks, request)
225/// ```
226pub fn try_service_commit<F: RepositoryFacts>(
227    invocation: &CommitInvocation<'_>,
228    checks: &mut ShippedChecks<F>,
229    request: &StageRequest,
230) -> Result<Option<ServiceCommit>, TransactionStop> {
231    let path_variable = |name: &str| -> Option<PathBuf> { return std::env::var_os(name).filter(|value| { return !value.is_empty(); }).map(PathBuf::from); };
232    let runtime: Option<PathBuf> = path_variable("XDG_RUNTIME_DIR");
233    let config_home: Option<PathBuf> = path_variable("XDG_CONFIG_HOME");
234    let home: Option<PathBuf> = path_variable("HOME");
235    let bus_address: Option<String> = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok();
236    let route: Route = choose_route(&RouteInputs {
237        runtime_dir: runtime.as_deref(),
238        bus_address: bus_address.as_deref(),
239        config_home: config_home.as_deref(),
240        home: home.as_deref(),
241        own_executable: remembered_own_executable(),
242        real_git: invocation.context.real_git.as_path(),
243    });
244    let Route::Service { address, provision: provisioning } = route else { return Ok(None) };
245    let region = invocation.region;
246    if region.include || region.interactive || region.patch || region.dry_run || region.amend || region.pathspec_from_file.is_some() {
247        return Ok(None);
248    }
249    let after: &[OsString] = invocation.arguments.get(invocation.subcommand_index + 1..).unwrap_or(&[]);
250    let Some(parsed) = parse_service_commit(after) else { return Ok(None) };
251    let quiet: bool = parsed.quiet;
252    let Some(committer) = identity(invocation.context, invocation.cwd, "GIT_COMMITTER_IDENT", &[])? else { return Ok(None) };
253    let Some(built) = build_message(invocation, &parsed, committer.as_str())? else { return Ok(None) };
254    let message: String = built.message;
255    let id: String = random_uuid().map_err(|error| { return failed(format!("an identity for this commit could not be made: {error:?}")); })?;
256    let author: String = match built.reused_author {
257        Some(reused) => reused,
258        None => {
259            let Some(made) = identity(invocation.context, invocation.cwd, "GIT_AUTHOR_IDENT", author_overrides(&parsed).as_slice())? else {
260                return Ok(None);
261            };
262            made
263        }
264    };
265    let allow_empty: bool = region.allow_empty;
266    if let Some(host) = provisioning.as_ref() {
267        // The first commit that needs the service sets up its socket activation; the manager starts the service.
268        provision(host).map_err(|failure| { return TransactionStop::Message(render(&failure)); })?;
269    }
270    let config: ClientConfig = ClientConfig { address, activation: Activation::Managed, start_wait: START_WAIT, commit_wait: COMMIT_WAIT };
271    let scratch_name: String = format!("cli-git-service-{id}");
272    for attempt in 0..LANDING_ATTEMPTS {
273        let ready: Box<Ready> = match prepare(invocation, checks, request, scratch_name.as_str(), allow_empty)? {
274            Prepared::Decline => return Ok(None),
275            Prepared::Blocked(result) => return Ok(Some(ServiceCommit { result, handoff: None })),
276            Prepared::Ready(ready) => ready,
277        };
278        let sent: CommitRequest = CommitRequest {
279            id: id.clone(),
280            repository: ready.repository.clone(),
281            common_dir: ready.common_dir.clone(),
282            reference: ready.reference.clone(),
283            base: ready.base.clone(),
284            changes: ready.changes.clone(),
285            message: message.clone(),
286            author: Some(author.clone()),
287            committer: committer.clone(),
288        };
289        match submit(&config, &sent, &mut std::io::stderr()) {
290            Ok(Submitted::Landed { commit, .. }) => {
291                if !quiet {
292                    let subject: &str = message.lines().next().unwrap_or("");
293                    let short: &str = commit.get(..7).unwrap_or(commit.as_str());
294                    let branch: &str = ready.reference.trim_start_matches("refs/heads/");
295                    let _printed = writeln!(std::io::stdout(), "[{branch} {short}] {subject}");
296                }
297                let handoff: ServiceHandoff = ServiceHandoff {
298                    config: config.clone(),
299                    repository: ready.repository.clone(),
300                    common_dir: ready.common_dir.clone(),
301                    reference: ready.reference.clone(),
302                    parent: ready.base.clone(),
303                };
304                let result: TransactionResult = TransactionResult { events: ready.events, exit_code: 0, landed_oid: Some(commit), uses_lfs: ready.uses_lfs };
305                return Ok(Some(ServiceCommit { result, handoff: Some(handoff) }));
306            }
307            Ok(Submitted::PrepareAgain { .. }) => {
308                if attempt + 1 == LANDING_ATTEMPTS {
309                    let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
310                    return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: LANDING_ATTEMPTS })));
311                }
312            }
313            Err(failure) => return Err(TransactionStop::Message(render(&failure))),
314        }
315    }
316    return Ok(None);
317}
318
319/// Service commit controls stay out of the release executable.
320#[cfg(test)]
321#[path = "service_commit_tests.rs"]
322mod tests;