anvilsign in

collin/anvil

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
43 if let Ok(platform) = repo.references()
44 && let Ok(iter) = platform.all()
45 {
46 for mut r in iter.flatten() {
47 let name = r.name().as_bstr().to_string();
48 if let Ok(id) = r.peel_to_id() {
49 refs.push((id.to_string(), name));
50 }
51 }
52 }
53
54 if refs.is_empty() {
55 // No refs at all: advertise capabilities with zero OID
56 let line = format!("{ZERO_OID} capabilities^{{}}\0{CAPABILITIES}\n");
57 output.extend_from_slice(&pktline::encode(line.as_bytes()));
58 } else {
59 // First ref line includes capabilities after NUL byte
60 let (oid, name) = &refs[0];
61 let first_line = format!("{oid} {name}\0{CAPABILITIES}\n");
62 output.extend_from_slice(&pktline::encode(first_line.as_bytes()));
63
64 // Subsequent ref lines
65 for (oid, name) in &refs[1..] {
66 let line = format!("{oid} {name}\n");
67 output.extend_from_slice(&pktline::encode(line.as_bytes()));
68 }
69 }
70
71 // Final flush
72 output.extend_from_slice(pktline::flush());
73
74 Ok(output)
75}
76
77#[cfg(test)]
78mod tests {
79 use std::{
80 path::{
81 Path,
82 PathBuf,
83 },
84 process::Command,
85 };
86
87 use tempfile::TempDir;
88
89 use super::*;
90
91 /// Create a bare repo with a single commit on the `main` branch.
92 ///
93 /// Returns the path to the bare repository.
94 fn create_repo_with_commit(root: &Path) -> PathBuf {
95 let bare_path = root.join("test.git");
96 let clone_path = root.join("workdir");
97
98 // Create a bare repository
99 let out = Command::new("git")
100 .args(["init", "--bare", bare_path.to_str().unwrap()])
101 .output()
102 .expect("git init --bare failed");
103 assert!(out.status.success(), "git init --bare failed: {:?}", out);
104
105 // Set default branch to main in bare repo
106 let out = Command::new("git")
107 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
108 .current_dir(&bare_path)
109 .output()
110 .expect("git symbolic-ref failed");
111 assert!(out.status.success());
112
113 // Clone the bare repo into a working directory
114 let out = Command::new("git")
115 .args([
116 "clone",
117 bare_path.to_str().unwrap(),
118 clone_path.to_str().unwrap(),
119 ])
120 .output()
121 .expect("git clone failed");
122 assert!(out.status.success(), "git clone failed: {:?}", out);
123
124 // Configure user in the clone
125 for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] {
126 Command::new("git")
127 .args(["config", key, val])
128 .current_dir(&clone_path)
129 .output()
130 .expect("git config failed");
131 }
132
133 // Create a commit
134 let out = Command::new("git")
135 .args(["commit", "--allow-empty", "-m", "initial commit"])
136 .current_dir(&clone_path)
137 .env("GIT_AUTHOR_NAME", "Test User")
138 .env("GIT_AUTHOR_EMAIL", "test@test.com")
139 .env("GIT_COMMITTER_NAME", "Test User")
140 .env("GIT_COMMITTER_EMAIL", "test@test.com")
141 .output()
142 .expect("git commit failed");
143 assert!(out.status.success(), "git commit failed: {:?}", out);
144
145 // Push to the bare repo
146 let out = Command::new("git")
147 .args(["push", "origin", "main"])
148 .current_dir(&clone_path)
149 .output()
150 .expect("git push failed");
151 assert!(out.status.success(), "git push failed: {:?}", out);
152
153 bare_path
154 }
155
156 #[test]
157 fn advertise_refs_starts_with_service_header() {
158 let dir = TempDir::new().unwrap();
159 let repo_path = create_repo_with_commit(dir.path());
160 let output = advertise_refs(&repo_path).unwrap();
161 let output_str = String::from_utf8_lossy(&output);
162
163 assert!(
164 output_str.starts_with("001e# service=git-upload-pack\n0000"),
165 "output does not start with service header: {output_str:?}"
166 );
167 }
168
169 #[test]
170 fn advertise_refs_contains_capabilities() {
171 let dir = TempDir::new().unwrap();
172 let repo_path = create_repo_with_commit(dir.path());
173 let output = advertise_refs(&repo_path).unwrap();
174 let output_str = String::from_utf8_lossy(&output);
175
176 assert!(
177 output_str.contains("multi_ack"),
178 "output missing multi_ack: {output_str:?}"
179 );
180 assert!(
181 output_str.contains("multi_ack_detailed"),
182 "output missing multi_ack_detailed: {output_str:?}"
183 );
184 assert!(
185 output_str.contains("side-band-64k"),
186 "output missing side-band-64k: {output_str:?}"
187 );
188 assert!(
189 output_str.contains("ofs-delta"),
190 "output missing ofs-delta: {output_str:?}"
191 );
192 assert!(
193 !output_str.contains("thin-pack"),
194 "output should not advertise thin-pack: {output_str:?}"
195 );
196 assert!(
197 !output_str.contains("shallow"),
198 "output should not advertise shallow: {output_str:?}"
199 );
200 }
201
202 #[test]
203 fn advertise_refs_contains_main_branch() {
204 let dir = TempDir::new().unwrap();
205 let repo_path = create_repo_with_commit(dir.path());
206 let output = advertise_refs(&repo_path).unwrap();
207 let output_str = String::from_utf8_lossy(&output);
208
209 assert!(
210 output_str.contains("refs/heads/main"),
211 "output missing refs/heads/main: {output_str:?}"
212 );
213 }
214
215 #[test]
216 fn advertise_refs_ends_with_flush() {
217 let dir = TempDir::new().unwrap();
218 let repo_path = create_repo_with_commit(dir.path());
219 let output = advertise_refs(&repo_path).unwrap();
220
221 assert!(
222 output.ends_with(b"0000"),
223 "output does not end with flush packet: {:?}",
224 String::from_utf8_lossy(&output)
225 );
226 }
227}