anvilsign in

collin/anvil

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