Skip to main content

git_policy_cli/landing/
service_route.rs

1//! What:
2//!  How a commit reaches the resident service: already listening, or set up first by the wrapper.
3//! Why:
4//!  There is no switch and no install state for the person to manage (`doc/planning/cli-git-resident-service.md`,
5//!  "Provisioning"). The route follows the machine: a user session bus and a service program next to the wrapper mean the
6//!  wrapper can set the service up itself, and keeps it set up (the socket is the manager's and outlives every upgrade, so
7//!  its presence never says the installed units name this release's program); a socket without them is used as it is;
8//!  anything else has no route, and the commit stops with the plain message of its platform, because the service is the only
9//!  writer of a commit. The decision reads the file system only and starts no process.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const route = chooseRoute({ runtimeDir, busAddress, configHome, homeDir, ownExecutable, realGit }); // undefined: no route
14//! ```
15
16use crate::repo_read_layout::read_layout_in;
17use crate::service_instance::{SOCKET_DIRECTORY, instance_of, socket_for};
18use crate::service_provision::{ProvisionHost, RETIREMENT_WAIT, SERVICE_PROGRAM};
19use crate::service_server::Bind;
20use crate::systemd_dbus::session_bus_path;
21use crate::transaction_git::{GitContext, GitRequest, run_git};
22use std::path::{Path, PathBuf};
23
24static OWN_EXECUTABLE: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
25
26/// Remembers where the running wrapper is, once, so the service program can be found beside it.
27///  Identification only: nothing is started from this path (`doc/planning/cli-git-no-self-invocation.md`); the path is
28///  read to name a sibling program in a unit file that the operating system's manager runs.
29pub fn remember_own_executable(path: &Path) {
30    let _first = OWN_EXECUTABLE.set(path.to_path_buf());
31}
32
33/// The wrapper's own path, when the process remembered it.
34pub fn remembered_own_executable() -> Option<&'static Path> {
35    return OWN_EXECUTABLE.get().map(PathBuf::as_path);
36}
37
38/// What the route decision reads.
39#[derive(Clone, Copy, Debug)]
40pub struct RouteInputs<'a> {
41    /// `XDG_RUNTIME_DIR`.
42    pub runtime_dir: Option<&'a Path>,
43    /// `DBUS_SESSION_BUS_ADDRESS`.
44    pub bus_address: Option<&'a str>,
45    /// `XDG_CONFIG_HOME`.
46    pub config_home: Option<&'a Path>,
47    /// `HOME`.
48    pub home: Option<&'a Path>,
49    /// The running wrapper's own executable.
50    pub own_executable: Option<&'a Path>,
51    /// The real Git the service will run.
52    pub real_git: &'a Path,
53    /// The worktree's own Git directory, which names the service instance the commit goes to.
54    pub git_dir: &'a Path,
55}
56
57/// How a commit reaches the service: through the service at `address`, after provisioning when `provision` is present.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct Route {
60    /// Where the service listens.
61    pub address: Bind,
62    /// What to set up, or to check is set up, first; absent only when a socket exists and the wrapper cannot set up
63    ///  a service itself (no session bus, or no service program beside it).
64    pub provision: Option<ProvisionHost>,
65}
66
67/// The directory a user's own unit files live in.
68pub fn user_unit_dir(config_home: Option<&Path>, home: Option<&Path>) -> Option<PathBuf> {
69    return config_home
70        .map(|directory| { return directory.join("systemd").join("user"); })
71        .or_else(|| { return home.map(|directory| { return directory.join(".config").join("systemd").join("user"); }); });
72}
73
74/// What:
75///  Decides the route.
76/// Why:
77///   Provisioning needs the session bus socket and the service program, and it is always handed back when both are there,
78///  even when the socket exists: the socket outlives an upgrade, and the units it activates may name a program the upgrade
79///  removed. Whether anything must be written is decided where the units are read (`provision`), so this decision stays
80///  free of reads. A socket that exists without the means to provision is used as it is, whoever made it; with neither
81///  the socket nor the means there is no route.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// chooseRoute(inputs) // undefined when nothing can reach or start a service
86/// ```
87pub fn choose_route(inputs: &RouteInputs<'_>) -> Option<Route> {
88    let runtime_dir = inputs.runtime_dir?;
89    let socket: PathBuf = socket_for(runtime_dir, inputs.git_dir);
90    let socket_exists: bool = socket.exists();
91    let Some(bus) = session_bus_path(inputs.bus_address, Some(runtime_dir)).filter(|path| { return path.exists(); }) else {
92        return if socket_exists { Some(Route { address: Bind::Path(socket), provision: None }) } else { None };
93    };
94    let Some(service_binary) = inputs
95        .own_executable
96        .and_then(Path::parent)
97        .map(|directory| { return directory.join(SERVICE_PROGRAM); })
98        .filter(|path| { return path.is_file(); })
99    else {
100        return if socket_exists { Some(Route { address: Bind::Path(socket), provision: None }) } else { None };
101    };
102    let host: ProvisionHost = ProvisionHost {
103        runtime_dir: runtime_dir.to_path_buf(),
104        user_unit_dir: user_unit_dir(inputs.config_home, inputs.home),
105        bus,
106        service_binary,
107        git: inputs.real_git.to_path_buf(),
108        instance: instance_of(inputs.git_dir),
109        retirement_wait: RETIREMENT_WAIT,
110    };
111    return Some(Route { address: Bind::Path(socket), provision: Some(host) });
112}
113
114/// What `git cli-git service status` prints and exits with.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct StatusReport {
117    /// Exit status: 0 when the service answered.
118    pub code: i32,
119    /// Standard output.
120    pub stdout: String,
121    /// Standard error.
122    pub stderr: String,
123}
124
125/// What:
126///  The real path of the Git directory of the worktree a command runs in, read from the file system when the layout is a plain
127///  one and from Git otherwise.
128/// Why:
129///   The Git directory names the service instance, so the route cannot be chosen without it; the preparation of the commit
130///  asks the same question later and shares the answer's cost when the file system can give it.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// worktreeGitDir(context, cwd) // undefined outside a repository
135/// ```
136pub fn worktree_git_dir(context: &GitContext, cwd: &Path) -> Option<PathBuf> {
137    if let Some(layout) = read_layout_in(cwd, context.overlay.as_slice()) {
138        return Some(layout.git_dir);
139    }
140    let output = run_git(context, &GitRequest::new(cwd, &["rev-parse", "--path-format=absolute", "--absolute-git-dir"])).ok()?;
141    if !output.succeeded() {
142        return None;
143    }
144    let text: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string();
145    return if text.is_empty() { None } else { Some(PathBuf::from(text)) };
146}
147
148/// What:
149///  The route to the service of a worktree from this process's environment.
150/// Why:
151///   The commit and the commands that hand a push over after they moved a branch reach the service the same way.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// routeFromEnvironment(realGit, gitDir) // undefined: no way to reach a service
156/// ```
157pub fn route_from_environment(real_git: &Path, git_dir: &Path) -> Option<Route> {
158    let path_variable = |name: &str| -> Option<PathBuf> { return std::env::var_os(name).filter(|value| { return !value.is_empty(); }).map(PathBuf::from); };
159    let runtime: Option<PathBuf> = path_variable("XDG_RUNTIME_DIR");
160    let config_home: Option<PathBuf> = path_variable("XDG_CONFIG_HOME");
161    let home: Option<PathBuf> = path_variable("HOME");
162    let bus_address: Option<String> = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok();
163    return choose_route(&RouteInputs {
164        runtime_dir: runtime.as_deref(),
165        bus_address: bus_address.as_deref(),
166        config_home: config_home.as_deref(),
167        home: home.as_deref(),
168        own_executable: remembered_own_executable(),
169        real_git,
170        git_dir,
171    });
172}
173
174/// What:
175///  Says whether the commit service of a worktree answers on its socket.
176/// Why:
177///   A diagnostic only: there is nothing to install or start by hand. Connecting is what a commit does, so a service that the
178///  manager would start on demand is started by this check too. Outside a repository there is no worktree to ask for, and the
179///  answer counts the instances that answer.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// serviceStatus(runtimeDir, gitDir)
184/// ```
185pub fn service_status(runtime_dir: Option<&Path>, git_dir: Option<&Path>) -> StatusReport {
186    let answers = |socket: &Path| -> bool { return std::os::unix::net::UnixStream::connect(socket).is_ok(); };
187    let (answered, what): (bool, &str) = match (runtime_dir, git_dir) {
188        (Some(runtime), Some(git)) => (answers(socket_for(runtime, git).as_path()), "The commit service of this worktree"),
189        (Some(runtime), None) => {
190            let any: bool = std::fs::read_dir(runtime.join(SOCKET_DIRECTORY)).is_ok_and(|entries| {
191                return entries.flatten().any(|entry| { return entry.path().extension().is_some_and(|extension| { return extension == "sock"; }) && answers(entry.path().as_path()); });
192            });
193            (any, "A commit service")
194        }
195        (None, _) => (false, "The commit service"),
196    };
197    return if answered {
198        StatusReport { code: 0, stdout: format!("{what} is running and answers.\n"), stderr: String::new() }
199    } else {
200        StatusReport {
201            code: 1,
202            stdout: String::new(),
203            stderr: format!(
204                "{what} does not answer here. It sets itself up the first time a commit needs it, so there is nothing to \
205                 install; if a commit fails, its message says what to do.\n"
206            ),
207        }
208    };
209}
210
211/// Route controls stay out of the release executable.
212#[cfg(test)]
213#[path = "service_route_tests.rs"]
214mod tests;