Skip to main content

git_policy_cli/
shadow_refs.rs

1//! What:
2//!  The shadow ref store: a snapshot of every real ref,
3//!       the private target ref,
4//!       symbolic refs,
5//!       and the private `HEAD`.
6//! Why:
7//!  Hooks resolve tags, other branches and remote-tracking refs as they stood at invocation.
8//!  With the files backend the snapshot is one `packed-refs` file and the private target is a loose ref,
9//!  which takes precedence over a packed entry;
10//!  with reftable the snapshot is one `update-ref --stdin` transaction
11//!  (`src/shadow-repository/shadow-refs.ts`).
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! await writeShadowRefs({ gitPath, cwd, shadowPath, capture });
16//! ```
17
18/// One shadow Git start, one real Git start, and their failure text.
19use super::shadow_git::shadow_request;
20use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git_checked};
21/// Private file writes for the snapshot.
22use super::private_storage::write_private_file;
23/// Journal enums: the ref backend, the base and the symbolic `HEAD` at invocation.
24use super::transaction_journal::{Base, RefFormat, SymbolicHead};
25/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
26use std::path::{Path, PathBuf};
27
28/// Header Git writes for a sorted `packed-refs` file without peeled lines.
29///  The trailing space matches Git's own writer, which older readers require.
30pub const PACKED_REFS_HEADER: &str = "# pack-refs with: sorted \n";
31
32/// What:
33///  One real ref at invocation.
34/// Why:
35///   Symbolic refs and direct refs take different snapshot forms.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// type RefSnapshotEntry = { name: string; oid: string; symref: string };
40/// ```
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct RefSnapshotEntry {
43    /// Full ref name.
44    pub name: String,
45    /// Object the ref resolves to.
46    pub oid: String,
47    /// Symbolic target, or empty for a direct ref.
48    pub symref: String,
49}
50
51/// What:
52///  Lists every real ref of the owning worktree in byte order.
53/// Why:
54///   Sorting by bytes matches the incumbent's `Buffer.compare` order in the packed file.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// await listRealRefs({ gitPath, cwd });
59/// ```
60pub fn list_real_refs(context: &GitContext, cwd: &Path) -> Result<Vec<RefSnapshotEntry>, GitFailure> {
61    let mut request: GitRequest = GitRequest::new(
62        cwd,
63        &["for-each-ref", "--format=%(refname)%00%(objectname)%00%(symref)"],
64    );
65    request.without_prefix = true;
66    let output: GitOutput = run_git_checked(context, &request)?;
67    let text: &str = std::str::from_utf8(output.stdout.as_slice())
68        .map_err(|_| { return GitFailure(String::from("git for-each-ref returned non-UTF-8 output.")); })?;
69    let mut refs: Vec<RefSnapshotEntry> = Vec::new();
70    for line in text.split('\n').filter(|line| { return !line.is_empty(); }) {
71        let fields: Vec<&str> = line.split('\0').collect();
72        let [name, oid, symref] = fields.as_slice() else {
73            return Err(GitFailure(format!(
74                "git for-each-ref returned a malformed line: {line:?}"
75            )));
76        };
77        refs.push(RefSnapshotEntry {
78            name: String::from(*name),
79            oid: String::from(*oid),
80            symref: String::from(*symref),
81        });
82    }
83    refs.sort_by(|left, right| { return left.name.as_bytes().cmp(right.name.as_bytes()); });
84    return Ok(refs);
85}
86
87/// What:
88///  Formats the files-backend snapshot of every direct ref except the private target.
89/// Why:
90///   The target is written loose, so a packed copy would only be shadowed.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// formatPackedRefs({ refs, targetRef });
95/// ```
96pub fn format_packed_refs(refs: &[RefSnapshotEntry], target_ref: &str) -> String {
97    let mut text: String = String::from(PACKED_REFS_HEADER);
98    for entry in refs
99        .iter()
100        .filter(|entry| { return entry.symref.is_empty() && entry.name != target_ref; })
101    {
102        text.push_str(format!("{} {}\n", entry.oid, entry.name).as_str());
103    }
104    return text;
105}
106
107/// What:
108///  Points the shadow `HEAD` at the private target:
109///  symbolic to the branch, or detached at the base.
110/// Why:
111///   A detached `HEAD` needs a base, and an unborn branch has no object to write.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// await writeShadowHead({ gitPath, shadowPath, symbolicHead, base });
116/// ```
117fn write_shadow_head(
118    context: &GitContext,
119    shadow: &Path,
120    symbolic_head: &SymbolicHead,
121    base: &Base,
122) -> Result<(), GitFailure> {
123    match symbolic_head {
124        SymbolicHead::Branch(reference) => {
125            if let Base::Commit(oid) = base {
126                run_git_checked(
127                    context,
128                    &shadow_request(shadow, shadow, &["update-ref", reference.as_str(), oid.as_str()]),
129                )?;
130            }
131            run_git_checked(
132                context,
133                &shadow_request(shadow, shadow, &["symbolic-ref", "HEAD", reference.as_str()]),
134            )?;
135            return Ok(());
136        }
137        SymbolicHead::Detached => {
138            let Base::Commit(oid) = base else {
139                return Err(GitFailure(String::from(
140                    "A detached HEAD has no unborn preparation base.",
141                )));
142            };
143            run_git_checked(
144                context,
145                &shadow_request(shadow, shadow, &["update-ref", "--no-deref", "HEAD", oid.as_str()]),
146            )?;
147            return Ok(());
148        }
149    }
150}
151
152/// What:
153///  Writes the shadow ref store:
154///  the snapshot, the symbolic refs, the private target ref and `HEAD`.
155/// Why:
156///   The order matches the incumbent's:
157///  symbolic refs come after their targets exist, and `HEAD` comes last.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// await writeShadowRefs({ gitPath, cwd, shadowPath, capture });
162/// ```
163pub fn write_shadow_refs(
164    context: &GitContext,
165    cwd: &Path,
166    shadow: &Path,
167    ref_format: RefFormat,
168    target_ref: &str,
169    symbolic_head: &SymbolicHead,
170    base: &Base,
171) -> Result<(), GitFailure> {
172    let refs: Vec<RefSnapshotEntry> = list_real_refs(context, cwd)?;
173    match ref_format {
174        RefFormat::Files => {
175            write_private_file(
176                PathBuf::from(shadow).join("packed-refs").as_path(),
177                format_packed_refs(&refs, target_ref).as_bytes(),
178            )
179            .map_err(|error| { return GitFailure(format!("writing packed-refs failed: {error}")); })?;
180        }
181        RefFormat::Reftable => {
182            let mut lines: String = String::new();
183            for entry in refs
184                .iter()
185                .filter(|entry| { return entry.symref.is_empty() && entry.name != target_ref; })
186            {
187                lines.push_str(format!("create {} {}\n", entry.name, entry.oid).as_str());
188            }
189            let mut request: GitRequest = shadow_request(shadow, shadow, &["update-ref", "--stdin"]);
190            request.input = Some(lines.into_bytes());
191            run_git_checked(context, &request)?;
192        }
193    }
194    for entry in refs.iter().filter(|entry| { return !entry.symref.is_empty(); }) {
195        run_git_checked(
196            context,
197            &shadow_request(shadow, shadow, &["symbolic-ref", entry.name.as_str(), entry.symref.as_str()]),
198        )?;
199    }
200    return write_shadow_head(context, shadow, symbolic_head, base);
201}
202
203/// Shadow ref controls stay out of the release executable.
204#[cfg(test)]
205#[path = "shadow_refs_tests.rs"]
206mod tests;