git_policy_cli/worktree_identity.rs
1//! What:
2//! Ask real Git which repository and worktree an invocation selects.
3//! Why:
4//! Configuration lives at the worktree top level,
5//! and worktree policies need to
6//! know main versus linked.
7//! Git applies `-C`,
8//! `--git-dir`,
9//! `--work-tree` and its
10//! environment itself,
11//! so the wrapper never re-implements that selection.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! // const identity = await resolveGitWorktreeIdentity({ args, gitPath });
16//! ```
17
18/// Import the captured-query runner and its byte helpers.
19use super::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git, strip_git_line};
20/// What:
21/// `OsString` is owned operating-system text of raw OS bytes (sibling `String`
22/// must be UTF-8).
23/// Why:
24/// The caller's global options are replayed to Git unchanged.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// // string[] argv, but byte-preserving.
29/// ```
30use std::ffi::OsString;
31/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
32use std::path::{Path, PathBuf};
33
34/// What:
35/// The repository shape an invocation selects.
36/// `#[derive(...)]` generates cloning,
37/// debug printing and `==`.
38/// Why:
39/// Each shape needs different handling:
40/// no repository means no configuration,
41/// a bare repository has no worktree,
42/// and main versus linked decides worktree
43/// policies.
44/// All paths are Git's own canonical absolute paths.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type WorktreeIdentity = { kind: 'outside-worktree' } | { kind: 'bare-repository'; ... } | ...;
49/// ```
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum WorktreeIdentity {
52 /// No repository,
53 /// or a repository location without a worktree (inside `.git`).
54 OutsideWorktree,
55 /// A bare repository:
56 /// Git directories but no worktree.
57 BareRepository {
58 /// Canonical common Git directory.
59 common_dir: PathBuf,
60 /// Canonical invocation-specific Git directory.
61 git_dir: PathBuf,
62 },
63 /// The main worktree:
64 /// its Git directory is the common directory.
65 MainWorktree {
66 /// Canonical common Git directory.
67 common_dir: PathBuf,
68 /// Canonical invocation-specific Git directory.
69 git_dir: PathBuf,
70 /// Canonical worktree top level,
71 /// where `cli-git.config.jsonc` lives.
72 worktree_root: PathBuf,
73 },
74 /// A linked worktree:
75 /// its Git directory is separate from the common directory.
76 LinkedWorktree {
77 /// Canonical common Git directory.
78 common_dir: PathBuf,
79 /// Canonical invocation-specific Git directory.
80 git_dir: PathBuf,
81 /// Canonical worktree top level,
82 /// where `cli-git.config.jsonc` lives.
83 worktree_root: PathBuf,
84 },
85}
86
87/// What:
88/// The `rev-parse` options of the identity query,
89/// in output order.
90/// `&[&str]` is a borrowed list of borrowed strings compiled into the program.
91/// Why:
92/// One query answers everything.
93/// `--show-toplevel` is last because Git stops
94/// there in a bare repository after already printing the first three lines.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// const IDENTITY_QUERY = ['rev-parse', '--path-format=absolute', ...] as const;
99/// ```
100pub const IDENTITY_QUERY: &[&str] = &[
101 "rev-parse",
102 "--path-format=absolute",
103 "--is-bare-repository",
104 "--git-dir",
105 "--git-common-dir",
106 "--show-toplevel",
107];
108
109/// What:
110/// Build the error for identity output this module cannot interpret.
111/// `std::io::Error::other` wraps a message as an input/output error.
112/// Why:
113/// Guessing a repository identity from unexpected output could apply the wrong
114/// configuration;
115/// failing names the limitation instead.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// function malformed(detail: string): Error;
120/// ```
121fn malformed(detail: &str) -> std::io::Error {
122 // `format!` builds the owned message text.
123 return std::io::Error::other(format!(
124 "cli-git could not classify the Git worktree: git rev-parse returned {detail}. \
125 Repository paths containing line feeds are not supported."
126 ));
127}
128
129/// What:
130/// Interpret the identity query's exit state and output.
131/// `std::io::Result<T>` is `Result<T, std::io::Error>`.
132/// Why:
133/// Success prints four lines.
134/// Failure after three lines is a repository without
135/// a worktree (bare when the first line says so).
136/// Failure with no output is
137/// "not a repository".
138/// Any other shape is rejected rather than guessed.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// function parseIdentityOutput(success: boolean, stdout: Buffer): WorktreeIdentity;
143/// ```
144pub fn parse_identity_output(success: bool, stdout: &[u8]) -> std::io::Result<WorktreeIdentity> {
145 if stdout.is_empty() {
146 if success {
147 // `Err(...)` is the failure variant.
148 return Err(malformed("no output"));
149 }
150 // `Ok(...)` is the success variant.
151 return Ok(WorktreeIdentity::OutsideWorktree);
152 }
153 // What: `Vec<&[u8]>` is an owned list of borrowed byte lines; `.split(...)` with a
154 // named predicate cuts at each line feed.
155 // Why: Each value Git printed occupies one line once the final line break is removed.
156 //
157 // In TS you'd write (pseudocode):
158 // ```ts
159 // const lines = stripGitLine(stdout).split('\n');
160 // ```
161 let mut lines: Vec<&[u8]> = Vec::<&[u8]>::new();
162 for line in strip_git_line(stdout).split(is_line_feed) {
163 // A Windows Git may end lines with `\r\n`; the `\r` is not part of the value.
164 lines.push(line.strip_suffix(b"\r").unwrap_or(line));
165 }
166 let expected: usize = if success { 4 } else { 3 };
167 if lines.len() != expected {
168 return Err(malformed(
169 format!("{} lines where {expected} were expected", lines.len()).as_str(),
170 ));
171 }
172 let bare: bool = lines[0] == b"true";
173 if !bare && lines[0] != b"false" {
174 return Err(malformed("an unknown bare-repository flag"));
175 }
176 // What: `let (Some(a), Some(b)) = (x, y) else { ... };` unwraps both or exits.
177 // Why: An empty directory line is not a path.
178 //
179 // In TS you'd write (pseudocode):
180 // ```ts
181 // if (gitDir === '' || commonDir === '') throw malformed('an empty Git directory');
182 // ```
183 let (Some(git_dir), Some(common_dir)) =
184 (path_from_git_bytes(lines[1]), path_from_git_bytes(lines[2]))
185 else {
186 return Err(malformed("an empty Git directory"));
187 };
188 if !success {
189 if bare {
190 return Ok(WorktreeIdentity::BareRepository {
191 common_dir,
192 git_dir,
193 });
194 }
195 return Ok(WorktreeIdentity::OutsideWorktree);
196 }
197 let Some(worktree_root) = path_from_git_bytes(lines[3]) else {
198 return Err(malformed("an empty worktree top level"));
199 };
200 if git_dir == common_dir {
201 return Ok(WorktreeIdentity::MainWorktree {
202 common_dir,
203 git_dir,
204 worktree_root,
205 });
206 }
207 return Ok(WorktreeIdentity::LinkedWorktree {
208 common_dir,
209 git_dir,
210 worktree_root,
211 });
212}
213
214/// Named predicate for splitting output into lines;
215/// `&u8` borrows one byte.
216fn is_line_feed(byte: &u8) -> bool {
217 return *byte == b'\n';
218}
219
220/// What:
221/// Resolve the identity selected by the caller's global Git options.
222/// `&[OsString]` borrows the arguments before the subcommand,
223/// unchanged.
224/// Why:
225/// Replaying the complete global prefix lets Git apply `-C` chains through
226/// symbolic links,
227/// `--git-dir`,
228/// `--work-tree` and environment selection exactly
229/// as it will for the forwarded command.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// async function resolveWorktreeIdentity(realGit, globalPrefix, overlay): Promise<WorktreeIdentity>;
234/// ```
235pub fn resolve_worktree_identity(
236 real_git: &Path,
237 global_prefix: &[OsString],
238 overlay: &[(OsString, OsString)],
239) -> std::io::Result<WorktreeIdentity> {
240 // `.to_vec()` copies the borrowed prefix into an owned, growable argument list.
241 let mut arguments: Vec<OsString> = global_prefix.to_vec();
242 // `for part in IDENTITY_QUERY` visits each compiled-in option.
243 for part in IDENTITY_QUERY {
244 arguments.push(OsString::from(part));
245 }
246 // A trailing `?` returns a start failure to our caller, or unwraps the output.
247 let output: MetadataOutput = run_metadata_git(real_git, arguments.as_slice(), overlay)?;
248 return parse_identity_output(output.success, output.stdout.as_slice());
249}
250
251/// What:
252/// The worktree top level of an identity,
253/// when it has one.
254/// `Option<&Path>` is "a borrowed path or nothing".
255/// Why:
256/// Configuration is loaded only for main and linked worktrees.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// function worktreeRoot(identity: WorktreeIdentity): string | undefined;
261/// ```
262pub fn worktree_root(identity: &WorktreeIdentity) -> Option<&Path> {
263 // What: `match` on the borrowed identity; `{ worktree_root, .. }` binds one field
264 // and ignores the rest; `|` joins two patterns sharing one arm.
265 // Why: Only the two worktree shapes carry a top level.
266 //
267 // In TS you'd write (pseudocode):
268 // ```ts
269 // return 'worktreeRoot' in identity ? identity.worktreeRoot : undefined;
270 // ```
271 match identity {
272 WorktreeIdentity::MainWorktree { worktree_root, .. }
273 | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => {
274 return Some(worktree_root.as_path());
275 }
276 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => {
277 return None;
278 }
279 }
280}
281
282/// Disposable-repository controls stay out of the release executable.
283#[cfg(test)]
284#[path = "worktree_identity_tests.rs"]
285mod tests;