Skip to main content

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;