anvilsign in

collin/anvil

main / vendor / gitserver-core / src / refs.rs
1use std::path::Path;
2
3use crate::{
4 error::Result,
5 pktline,
6};
7
8const CAPABILITIES: &str = "multi_ack multi_ack_detailed side-band-64k ofs-delta";
9
10const ZERO_OID: &str = "0000000000000000000000000000000000000000";
11
12/// Generate the complete pkt-line encoded ref advertisement response
13/// for the Git smart HTTP protocol (`git-upload-pack`).
14///
15/// The output follows the format expected by `git clone` / `git fetch`:
16///
17/// ```text
18/// 001e# service=git-upload-pack\n
19/// 0000
20/// <first-ref>\0<capabilities>\n
21/// <ref-line>\n
22/// ...
23/// 0000
24/// ```
25pub fn advertise_refs(repo_path: &Path) -> Result<Vec<u8>> {
26 let repo = gix::open(repo_path)?;
27
28 let mut output = Vec::new();
29
30 // Service header + flush
31 output.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
32 output.extend_from_slice(pktline::flush());
33
34 // Collect refs: (oid_hex, refname)
35 let mut refs: Vec<(String, String)> = Vec::new();
36
37 // Try to resolve HEAD first
38 if let Ok(id) = repo.head_id() {
39 refs.push((id.to_string(), "HEAD".to_string()));
40 }
41
42 // Iterate all references. Each line advertises the ref's *direct* target
43 // (for an annotated tag, the tag object); the peeled commit follows on a
44 // conventional `<refname>^{}` line, as git's own advertisement does.
45 if let Ok(platform) = repo.references()
46 && let Ok(iter) = platform.all()
47 {
48 for mut r in iter.flatten() {
49 let name = r.name().as_bstr().to_string();
50 let Some(direct) = r.try_id().map(|id| id.detach()) else {
51 continue;
52 };
53 refs.push((direct.to_string(), name.clone()));
54 if let Ok(peeled) = r.peel_to_id()
55 && peeled.detach() != direct
56 {
57 refs.push((peeled.to_string(), format!("{name}^{{}}")));
58 }
59 }
60 }
61
62 if refs.is_empty() {
63 // No refs at all: advertise capabilities with zero OID
64 let line = format!("{ZERO_OID} capabilities^{{}}\0{CAPABILITIES}\n");
65 output.extend_from_slice(&pktline::encode(line.as_bytes()));
66 } else {
67 // First ref line includes capabilities after NUL byte
68 let (oid, name) = &refs[0];
69 let first_line = format!("{oid} {name}\0{CAPABILITIES}\n");
70 output.extend_from_slice(&pktline::encode(first_line.as_bytes()));
71
72 // Subsequent ref lines
73 for (oid, name) in &refs[1..] {
74 let line = format!("{oid} {name}\n");
75 output.extend_from_slice(&pktline::encode(line.as_bytes()));
76 }
77 }
78
79 // Final flush
80 output.extend_from_slice(pktline::flush());
81
82 Ok(output)
83}
84
85#[cfg(test)]
86mod tests {
87 use std::{
88 path::{
89 Path,
90 PathBuf,
91 },
92 process::Command,
93 };
94
95 use tempfile::TempDir;
96
97 use super::*;
98
99 /// Create a bare repo with a single commit on the `main` branch.
100 ///
101 /// Returns the path to the bare repository.
102 fn create_repo_with_commit(root: &Path) -> PathBuf {
103 let bare_path = root.join("test.git");
104 let clone_path = root.join("workdir");
105
106 // Create a bare repository
107 let out = Command::new("git")
108 .args(["init", "--bare", bare_path.to_str().unwrap()])
109 .output()
110 .expect("git init --bare failed");
111 assert!(out.status.success(), "git init --bare failed: {:?}", out);
112
113 // Set default branch to main in bare repo
114 let out = Command::new("git")
115 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
116 .current_dir(&bare_path)
117 .output()
118 .expect("git symbolic-ref failed");
119 assert!(out.status.success());
120
121 // Clone the bare repo into a working directory
122 let out = Command::new("git")
123 .args([
124 "clone",
125 bare_path.to_str().unwrap(),
126 clone_path.to_str().unwrap(),
127 ])
128 .output()
129 .expect("git clone failed");
130 assert!(out.status.success(), "git clone failed: {:?}", out);
131
132 // Configure user in the clone
133 for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] {
134 Command::new("git")
135 .args(["config", key, val])
136 .current_dir(&clone_path)
137 .output()
138 .expect("git config failed");
139 }
140
141 // Create a commit
142 let out = Command::new("git")
143 .args(["commit", "--allow-empty", "-m", "initial commit"])
144 .current_dir(&clone_path)
145 .env("GIT_AUTHOR_NAME", "Test User")
146 .env("GIT_AUTHOR_EMAIL", "test@test.com")
147 .env("GIT_COMMITTER_NAME", "Test User")
148 .env("GIT_COMMITTER_EMAIL", "test@test.com")
149 .output()
150 .expect("git commit failed");
151 assert!(out.status.success(), "git commit failed: {:?}", out);
152
153 // Push to the bare repo
154 let out = Command::new("git")
155 .args(["push", "origin", "main"])
156 .current_dir(&clone_path)
157 .output()
158 .expect("git push failed");
159 assert!(out.status.success(), "git push failed: {:?}", out);
160
161 bare_path
162 }
163
164 #[test]
165 fn advertise_refs_starts_with_service_header() {
166 let dir = TempDir::new().unwrap();
167 let repo_path = create_repo_with_commit(dir.path());
168 let output = advertise_refs(&repo_path).unwrap();
169 let output_str = String::from_utf8_lossy(&output);
170
171 assert!(
172 output_str.starts_with("001e# service=git-upload-pack\n0000"),
173 "output does not start with service header: {output_str:?}"
174 );
175 }
176
177 #[test]
178 fn advertise_refs_contains_capabilities() {
179 let dir = TempDir::new().unwrap();
180 let repo_path = create_repo_with_commit(dir.path());
181 let output = advertise_refs(&repo_path).unwrap();
182 let output_str = String::from_utf8_lossy(&output);
183
184 assert!(
185 output_str.contains("multi_ack"),
186 "output missing multi_ack: {output_str:?}"
187 );
188 assert!(
189 output_str.contains("multi_ack_detailed"),
190 "output missing multi_ack_detailed: {output_str:?}"
191 );
192 assert!(
193 output_str.contains("side-band-64k"),
194 "output missing side-band-64k: {output_str:?}"
195 );
196 assert!(
197 output_str.contains("ofs-delta"),
198 "output missing ofs-delta: {output_str:?}"
199 );
200 assert!(
201 !output_str.contains("thin-pack"),
202 "output should not advertise thin-pack: {output_str:?}"
203 );
204 assert!(
205 !output_str.contains("shallow"),
206 "output should not advertise shallow: {output_str:?}"
207 );
208 }
209
210 #[test]
211 fn advertise_refs_contains_main_branch() {
212 let dir = TempDir::new().unwrap();
213 let repo_path = create_repo_with_commit(dir.path());
214 let output = advertise_refs(&repo_path).unwrap();
215 let output_str = String::from_utf8_lossy(&output);
216
217 assert!(
218 output_str.contains("refs/heads/main"),
219 "output missing refs/heads/main: {output_str:?}"
220 );
221 }
222
223 #[test]
224 fn advertise_refs_ends_with_flush() {
225 let dir = TempDir::new().unwrap();
226 let repo_path = create_repo_with_commit(dir.path());
227 let output = advertise_refs(&repo_path).unwrap();
228
229 assert!(
230 output.ends_with(b"0000"),
231 "output does not end with flush packet: {:?}",
232 String::from_utf8_lossy(&output)
233 );
234 }
235}