git_policy_cli/shadow_links.rs
1//! What:
2//! Links every non-private entry of the real common Git directory into a shadow repository,
3//! and copies the per-worktree entries (`config.worktree`, a linked worktree's sparse checkout).
4//! Why:
5//! Shared entries such as `hooks`, `info`, `rr-cache`, `lfs` and `modules` must be the real ones,
6//! so hooks and native Git see them;
7//! transaction-private entries (refs, index, objects, conclusion state, lock files) stay the shadow's own
8//! (`src/shadow-repository/shadow-links.ts`).
9//! On Windows the incumbent uses junctions and copies;
10//! this port uses directory symbolic links and copies, because the standard library has no junction API.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! await linkShadowEntries({ shadowPath, commonDir, gitDir });
15//! ```
16
17/// The conclusion state names a shadow keeps privately.
18use super::conclusion_cleanup::{CONCLUSION_STATE_FILES, SEQUENCER_DIRECTORY};
19/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
20use std::path::{Path, PathBuf};
21
22/// Real common-directory entries the shadow never links:
23/// its ref store, index, config, object store, message file, copied conclusion state,
24/// and cli-git's own state, which contains the shadow itself.
25pub const PRIVATE_SHADOW_ENTRIES: [&str; 13] = [
26 "HEAD",
27 "refs",
28 "packed-refs",
29 "reftable",
30 "logs",
31 "index",
32 "config",
33 "config.worktree",
34 "objects",
35 "COMMIT_EDITMSG",
36 "cli-git",
37 "cli-git-transactions",
38 "cli-git-transaction",
39];
40
41/// What:
42/// Reports whether an entry must stay private to the shadow.
43/// Why:
44/// A shadow Git that locks the same name as the real owner would collide with it,
45/// so lock files are never linked.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// isPrivateShadowEntry(name)
50/// ```
51pub fn is_private_shadow_entry(name: &str) -> bool {
52 return PRIVATE_SHADOW_ENTRIES.contains(&name)
53 || CONCLUSION_STATE_FILES.contains(&name)
54 || name == SEQUENCER_DIRECTORY
55 || name.ends_with(".lock");
56}
57
58/// What:
59/// Removes whatever sits at a shadow entry name, without following a link.
60/// Why:
61/// A link in the shadow is removed itself, never its target in the real repository.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// await rm(destination, { recursive: true, force: true });
66/// ```
67fn remove_entry(path: &Path) -> std::io::Result<()> {
68 return match std::fs::symlink_metadata(path) {
69 Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path),
70 Ok(_) => std::fs::remove_file(path),
71 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
72 Err(error) => Err(error),
73 };
74}
75
76/// What:
77/// Shares one real entry with the shadow.
78/// Why:
79/// Unix links every entry;
80/// Windows links directories and copies files, since a file link needs a privilege.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// await symlink(source, destination);
85/// ```
86fn share_entry(source: &Path, destination: &Path, directory: bool) -> std::io::Result<()> {
87 #[cfg(unix)]
88 {
89 let _ = directory;
90 return std::os::unix::fs::symlink(source, destination);
91 }
92 #[cfg(windows)]
93 {
94 if directory {
95 return std::os::windows::fs::symlink_dir(source, destination);
96 }
97 return std::fs::copy(source, destination).map(|_| ());
98 }
99}
100
101/// What:
102/// Links every shared entry of one real directory into one shadow directory.
103/// Why:
104/// Entries are typed without following links,
105/// so a link in the real directory is shared as a link.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// await linkDirectoryEntries({ sourceDirectory, shadowDirectory, skip });
110/// ```
111fn link_directory_entries(
112 source_directory: &Path,
113 shadow_directory: &Path,
114 skip: impl Fn(&str) -> bool,
115) -> std::io::Result<()> {
116 for entry_result in std::fs::read_dir(source_directory)? {
117 let entry: std::fs::DirEntry = entry_result?;
118 let name: String = entry.file_name().to_string_lossy().into_owned();
119 if skip(name.as_str()) {
120 continue;
121 }
122 let destination: PathBuf = shadow_directory.join(&name);
123 // `git init` may have created a template entry of the same name; the real one replaces it.
124 remove_entry(destination.as_path())?;
125 share_entry(
126 entry.path().as_path(),
127 destination.as_path(),
128 entry.file_type()?.is_dir(),
129 )?;
130 }
131 return Ok(());
132}
133
134/// What:
135/// Reports whether a regular per-worktree file exists.
136/// Why:
137/// A missing file is an answer, every other failure is an error.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// await regularFileExists(path);
142/// ```
143fn regular_file_exists(path: &Path) -> std::io::Result<bool> {
144 return match std::fs::symlink_metadata(path) {
145 Ok(metadata) => Ok(metadata.is_file()),
146 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
147 Err(error) => Err(error),
148 };
149}
150
151/// What:
152/// Links the real common directory into the shadow and copies the per-worktree entries.
153/// Why:
154/// A linked worktree's `info/sparse-checkout` lives outside the common directory,
155/// so its `info` directory is private and only its shared entries are linked.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// await linkShadowEntries({ shadowPath, commonDir, gitDir });
160/// ```
161pub fn link_shadow_entries(shadow: &Path, common_dir: &Path, git_dir: &Path) -> std::io::Result<()> {
162 let per_worktree_sparse: PathBuf = git_dir.join("info").join("sparse-checkout");
163 let mixed_info: bool =
164 git_dir != common_dir && regular_file_exists(per_worktree_sparse.as_path())?;
165 link_directory_entries(common_dir, shadow, |name| {
166 return is_private_shadow_entry(name) || (mixed_info && name == "info");
167 })?;
168 let worktree_config: PathBuf = git_dir.join("config.worktree");
169 if regular_file_exists(worktree_config.as_path())? {
170 std::fs::copy(&worktree_config, shadow.join("config.worktree"))?;
171 }
172 if !mixed_info {
173 return Ok(());
174 }
175 let shadow_info: PathBuf = shadow.join("info");
176 remove_entry(shadow_info.as_path())?;
177 std::fs::create_dir(&shadow_info)?;
178 set_private_directory(shadow_info.as_path())?;
179 let shared_info: PathBuf = common_dir.join("info");
180 match link_directory_entries(shared_info.as_path(), shadow_info.as_path(), |name| {
181 return name == "sparse-checkout";
182 }) {
183 Ok(()) => {}
184 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
185 Err(error) => return Err(error),
186 }
187 std::fs::copy(&per_worktree_sparse, shadow_info.join("sparse-checkout"))?;
188 return Ok(());
189}
190
191/// What:
192/// Gives a directory the private mode of the transaction's directories.
193/// Why:
194/// The shadow's private `info` holds a copy of per-worktree state.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// await mkdir(shadowInfo, { mode: 0o700 });
199/// ```
200#[cfg(unix)]
201fn set_private_directory(path: &Path) -> std::io::Result<()> {
202 use std::os::unix::fs::PermissionsExt;
203 return std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
204}
205
206/// Windows has no mode bits for this directory; the incumbent's ACL work is not ported.
207#[cfg(windows)]
208fn set_private_directory(_path: &Path) -> std::io::Result<()> {
209 return Ok(());
210}
211
212/// Shadow link controls stay out of the release executable.
213#[cfg(test)]
214#[path = "shadow_links_tests.rs"]
215mod tests;