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 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
93 #[cfg(windows)]
94 {
95 if directory {
96 return std::os::windows::fs::symlink_dir(source, destination);
97 }
98 return std::fs::copy(source, destination).map(|_| ());
99 }
100}
101
102/// What:
103/// Links every shared entry of one real directory into one shadow directory.
104/// Why:
105/// Entries are typed without following links,
106/// so a link in the real directory is shared as a link.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// await linkDirectoryEntries({ sourceDirectory, shadowDirectory, skip });
111/// ```
112fn link_directory_entries(
113 source_directory: &Path,
114 shadow_directory: &Path,
115 skip: impl Fn(&str) -> bool,
116) -> std::io::Result<()> {
117 for entry_result in std::fs::read_dir(source_directory)? {
118 let entry: std::fs::DirEntry = entry_result?;
119 let name: String = entry.file_name().to_string_lossy().into_owned();
120 if skip(name.as_str()) {
121 continue;
122 }
123 let destination: PathBuf = shadow_directory.join(&name);
124 // `git init` may have created a template entry of the same name; the real one replaces it.
125 remove_entry(destination.as_path())?;
126 share_entry(
127 entry.path().as_path(),
128 destination.as_path(),
129 entry.file_type()?.is_dir(),
130 )?;
131 }
132 return Ok(());
133}
134
135/// What:
136/// Reports whether a regular per-worktree file exists.
137/// Why:
138/// A missing file is an answer, every other failure is an error.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// await regularFileExists(path);
143/// ```
144fn regular_file_exists(path: &Path) -> std::io::Result<bool> {
145 return match std::fs::symlink_metadata(path) {
146 Ok(metadata) => Ok(metadata.is_file()),
147 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
148 Err(error) => Err(error),
149 };
150}
151
152/// What:
153/// Links the real common directory into the shadow and copies the per-worktree entries.
154/// Why:
155/// A linked worktree's `info/sparse-checkout` lives outside the common directory,
156/// so its `info` directory is private and only its shared entries are linked.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// await linkShadowEntries({ shadowPath, commonDir, gitDir });
161/// ```
162pub fn link_shadow_entries(shadow: &Path, common_dir: &Path, git_dir: &Path) -> std::io::Result<()> {
163 let per_worktree_sparse: PathBuf = git_dir.join("info").join("sparse-checkout");
164 let mixed_info: bool =
165 git_dir != common_dir && regular_file_exists(per_worktree_sparse.as_path())?;
166 link_directory_entries(common_dir, shadow, |name| {
167 return is_private_shadow_entry(name) || (mixed_info && name == "info");
168 })?;
169 let worktree_config: PathBuf = git_dir.join("config.worktree");
170 if regular_file_exists(worktree_config.as_path())? {
171 std::fs::copy(&worktree_config, shadow.join("config.worktree"))?;
172 }
173 if !mixed_info {
174 return Ok(());
175 }
176 let shadow_info: PathBuf = shadow.join("info");
177 remove_entry(shadow_info.as_path())?;
178 std::fs::create_dir(&shadow_info)?;
179 set_private_directory(shadow_info.as_path())?;
180 let shared_info: PathBuf = common_dir.join("info");
181 match link_directory_entries(shared_info.as_path(), shadow_info.as_path(), |name| {
182 return name == "sparse-checkout";
183 }) {
184 Ok(()) => {}
185 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
186 Err(error) => return Err(error),
187 }
188 std::fs::copy(&per_worktree_sparse, shadow_info.join("sparse-checkout"))?;
189 return Ok(());
190}
191
192/// What:
193/// Gives a directory the private mode of the transaction's directories.
194/// Why:
195/// The shadow's private `info` holds a copy of per-worktree state.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// await mkdir(shadowInfo, { mode: 0o700 });
200/// ```
201#[cfg(unix)]
202fn set_private_directory(path: &Path) -> std::io::Result<()> {
203 use std::os::unix::fs::PermissionsExt;
204 return std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
205}
206
207/// Windows has no mode bits for this directory; the incumbent's ACL work is not ported.
208// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
209#[cfg(windows)]
210fn set_private_directory(_path: &Path) -> std::io::Result<()> {
211 return Ok(());
212}
213
214/// Shadow link controls stay out of the release executable.
215#[cfg(test)]
216#[path = "shadow_links_tests.rs"]
217mod tests;