Skip to main content

git_policy_cli/
effective_target.rs

1//! What:
2//!  Classify the worktree an invocation targets for worktree-enforcing policies.
3//! Why:
4//!  Linked-worktree safeguards discipline a person's repositories,
5//!  not the
6//!      disposable clones a tool keeps in its own cache.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // classifyEffectiveTarget(identity, allowedDirs) === 'linked-worktree'
11//! ```
12
13/// Import getenv-style lookup and the resolved repository identity.
14use super::child_environment::environment_value;
15use super::worktree_identity::WorktreeIdentity;
16/// What:
17///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
18///       must be UTF-8).
19/// Why:
20///   Cache directories come from environment values that need not be UTF-8.
21///
22/// In TS you'd write (pseudocode):
23/// ```ts
24/// // process.env values, but byte-preserving.
25/// ```
26use std::ffi::OsString;
27/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
28use std::path::{Path, PathBuf};
29
30/// What:
31///  The four targets worktree policies distinguish.
32///       `#[derive(...)]` generates copying,
33///  debug printing and `==`.
34/// Why:
35///   A bare repository or no repository has no worktree to protect;
36///  an
37///       allowlisted tool cache is exempt;
38///  main versus linked decides enforcement.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type EffectiveTarget = 'outside-worktree' | 'main-worktree' | 'linked-worktree' | 'allowlisted';
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum EffectiveTarget {
46    /// No worktree:
47    ///  outside any repository,
48    ///  or a bare repository.
49    OutsideWorktree,
50    /// The repository's main worktree.
51    MainWorktree,
52    /// A linked worktree.
53    LinkedWorktree,
54    /// A repository under a tool-cache directory exempt from enforcement.
55    Allowlisted,
56}
57
58/// What:
59///  Resolve uv's cache directory the way uv does:
60///  `UV_CACHE_DIR`,
61///  then
62///       `XDG_CACHE_HOME/uv`,
63///  then `HOME/.cache/uv`;
64///  an empty value counts as unset.
65///       `Option<&Path>` is "a borrowed home directory or nothing";
66///       `Option<PathBuf>` is "an owned path or nothing".
67/// Why:
68///   uv runs destructive Git inside throwaway clones it owns there.
69///  The home
70///       directory is injected so tests never read the real one.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// function uvCacheDir(environment: [string, string][], home?: string): string | undefined;
75/// ```
76pub fn uv_cache_dir(environment: &[(OsString, OsString)], home: Option<&Path>) -> Option<PathBuf> {
77    // `if let Some(x) = ... && cond` runs only when the variable is set and the second
78    // condition also holds, like `x !== undefined && x !== ""`.
79    if let Some(explicit) = environment_value(environment, "UV_CACHE_DIR")
80        && !explicit.is_empty()
81    {
82        // `Some(...)` is the "present" variant.
83        return Some(PathBuf::from(explicit));
84    }
85    if let Some(cache_home) = environment_value(environment, "XDG_CACHE_HOME")
86        && !cache_home.is_empty()
87    {
88        // `.join` appends one path segment and returns an owned path.
89        return Some(PathBuf::from(cache_home).join("uv"));
90    }
91    // What: `let Some(x) = ... else { return None; };` unwraps the home or exits.
92    // Why:  Without any hint and without a home directory there is no cache to exempt.
93    //
94    // In TS you'd write (pseudocode):
95    // ```ts
96    // if (home === undefined) return undefined;
97    // ```
98    let Some(home_directory) = home else {
99        // `None` is the "absent" variant.
100        return None;
101    };
102    return Some(home_directory.join(".cache").join("uv"));
103}
104
105/// What:
106///  Resolve mise's data directory the way mise does:
107///  `MISE_DATA_DIR`,
108///  then
109///       `XDG_DATA_HOME/mise`,
110///  then `HOME/.local/share/mise`;
111///  an empty value counts as unset.
112///       `Option<&Path>` is "a borrowed home directory or nothing";
113///       `Option<PathBuf>` is "an owned path or nothing".
114/// Why:
115///   mise keeps each tool plugin as a Git clone under `plugins/` there and updates it with a
116///       forced checkout, which the main-worktree rule would reject.
117///  The home directory is injected so
118///       tests never read the real one.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function miseDataDir(environment: [string, string][], home?: string): string | undefined;
123/// ```
124pub fn mise_data_dir(environment: &[(OsString, OsString)], home: Option<&Path>) -> Option<PathBuf> {
125    // `if let Some(x) = ... && cond` runs only when the variable is set and the second
126    // condition also holds, like `x !== undefined && x !== ""`.
127    if let Some(explicit) = environment_value(environment, "MISE_DATA_DIR")
128        && !explicit.is_empty()
129    {
130        // `Some(...)` is the "present" variant.
131        return Some(PathBuf::from(explicit));
132    }
133    if let Some(data_home) = environment_value(environment, "XDG_DATA_HOME")
134        && !data_home.is_empty()
135    {
136        // `.join` appends one path segment and returns an owned path.
137        return Some(PathBuf::from(data_home).join("mise"));
138    }
139    // What: `let Some(x) = ... else { return None; };` unwraps the home or exits.
140    // Why:  Without any hint and without a home directory there is no data directory to exempt.
141    //
142    // In TS you'd write (pseudocode):
143    // ```ts
144    // if (home === undefined) return undefined;
145    // ```
146    let Some(home_directory) = home else {
147        // `None` is the "absent" variant.
148        return None;
149    };
150    return Some(home_directory.join(".local").join("share").join("mise"));
151}
152
153/// What:
154///  The tool directories exempt from worktree enforcement on this machine.
155///       `Vec<PathBuf>` is an owned list of owned paths.
156/// Why:
157///   The set is a property of installed tooling,
158///  not of any repository,
159///  so it is
160///       compiled in rather than configured.
161///  Each entry is a directory whose Git clones a tool
162///       creates and discards itself.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// function defaultAllowedWorktreeDirs(environment, home): string[];
167/// ```
168pub fn default_allowed_worktree_dirs(
169    environment: &[(OsString, OsString)],
170    home: Option<&Path>,
171) -> Vec<PathBuf> {
172    // `Vec::<PathBuf>::new()` is an empty owned list; `mut` allows pushing.
173    let mut directories: Vec<PathBuf> = Vec::<PathBuf>::new();
174    if let Some(uv) = uv_cache_dir(environment, home) {
175        directories.push(uv);
176    }
177    if let Some(mise) = mise_data_dir(environment, home) {
178        directories.push(mise);
179    }
180    return directories;
181}
182
183/// What:
184///  Report whether the canonical `candidate` lies inside any allowed directory.
185/// Why:
186///   Each allowed directory is resolved through symbolic links first,
187///  so a home
188///       reached through a link still matches;
189///  a directory that does not exist drops
190///       out.
191///  `Path::starts_with` compares whole segments,
192///  so `/a/b` does not contain
193///       `/a/bc`.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// async function isAllowedWorktreeDir(candidate: string, allowedDirs: string[]): Promise<boolean>;
198/// ```
199pub fn is_allowed_worktree_dir(candidate: &Path, allowed_dirs: &[PathBuf]) -> bool {
200    // `for directory in allowed_dirs` borrows each entry in turn.
201    for directory in allowed_dirs {
202        // What: `if let Ok(resolved) = ... && cond` runs only when the directory could be
203        //       resolved and the candidate lies inside it.
204        // Why:  A tool that is not installed has no cache directory to compare with.
205        //
206        // In TS you'd write (pseudocode):
207        // ```ts
208        // let resolved; try { resolved = await realpath(directory); } catch { continue; }
209        // ```
210        if let Ok(resolved) = std::fs::canonicalize(directory)
211            && candidate.starts_with(resolved.as_path())
212        {
213            return true;
214        }
215    }
216    return false;
217}
218
219/// What:
220///  Map a repository identity to the target worktree policies act on.
221/// Why:
222///   Bare and absent repositories have no worktree;
223///  a repository whose Git
224///       directory sits under an allowed tool cache is exempt before main/linked matters.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// function classifyEffectiveTarget(identity: WorktreeIdentity, allowedDirs: string[]): EffectiveTarget;
229/// ```
230pub fn classify_effective_target(
231    identity: &WorktreeIdentity,
232    allowed_dirs: &[PathBuf],
233) -> EffectiveTarget {
234    // What: `match` on the borrowed identity; `{ git_dir, .. }` binds one field and
235    //       ignores the rest.
236    // Why:  Each shape maps to exactly one target, and the compiler checks none is missed.
237    //
238    // In TS you'd write (pseudocode):
239    // ```ts
240    // switch (identity.kind) { case 'outside-worktree': return 'outside-worktree'; ... }
241    // ```
242    match identity {
243        WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => {
244            return EffectiveTarget::OutsideWorktree;
245        }
246        WorktreeIdentity::MainWorktree { git_dir, .. } => {
247            if is_allowed_worktree_dir(git_dir.as_path(), allowed_dirs) {
248                return EffectiveTarget::Allowlisted;
249            }
250            return EffectiveTarget::MainWorktree;
251        }
252        WorktreeIdentity::LinkedWorktree { git_dir, .. } => {
253            if is_allowed_worktree_dir(git_dir.as_path(), allowed_dirs) {
254                return EffectiveTarget::Allowlisted;
255            }
256            return EffectiveTarget::LinkedWorktree;
257        }
258    }
259}
260
261/// Disposable-directory controls stay out of the release executable.
262#[cfg(test)]
263#[path = "effective_target_tests.rs"]
264mod tests;