Skip to content

Commit c9c9ec4

Browse files
committed
fix(protocol): omit v2 zero-tip pseudo refs (HP-16)
Signed-off-by: Eli Ma <eli@patch.sh>
1 parent 70f75c2 commit c9c9ec4

7 files changed

Lines changed: 337 additions & 76 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mega2"
3-
version = "0.42.10"
3+
version = "0.42.11"
44
edition = "2024"
55
license = "MIT OR Apache-2.0"
66
description = "Monorepo engine and Git hosting server for AI agents: trunk-based storage, Git Smart HTTP, Git LFS, OCI registry, and Agent Session Capture"

‎docs/plan/plan-20261002.md‎

Lines changed: 24 additions & 19 deletions
Large diffs are not rendered by default.

‎docs/refactoring/protocol.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,10 @@ buffer。
545545
- protocol v1 upload-pack 解析 `deepen` / `deepen-relative`,并返回 `shallow` response。
546546
- `deepen-since` / `deepen-not` 会明确返回 `ProtocolError::InvalidInput`,不 silent misbehave。
547547
- HTTP 和 SSH 均支持 protocol v2 capability advertisement、`ls-refs` 和 `fetch`。
548+
- protocol v2 `ls-refs` 在 head 为零 ID 时不写 HEAD 行,因此不会输出
549+
`capabilities^{}` 伪 ref 或 `symref-target`;
550+
`ls_refs_zero_head_flush_only` 锁定空 ref 列表只返回 flush。v0 `info/refs`
551+
保留伪 ref,以承载 capability 列表。
548552
- protocol v2 `fetch` 支持 `filter blob:none`,pack 只发送 commits + trees,不发送 blob objects。
549553

550554
仍需后续确认或扩展:
@@ -636,6 +640,7 @@ SSH 中 `git-lfs-transfer` 返回明确 unsupported failure,`git-lfs-authentic
636640
| 用户命令 | HTTP | SSH | Auth | Repo-shape |
637641
|---|---|---|---|---|
638642
| ls-remote | smoke:HTTP_ls-remote | smoke:SSH_ls-remote | N/A+只读广告默认匿名 | N/A+Monorepo根路径;ImportRepo见DEFER-GM-01 |
643+
| v2 ls-refs 空仓库(不存在的 `/<path>.git`) | cargo:integration_git_cli_v2_missing_path_empty | N/A+与 HTTP 共用 `handle_v2_ls_refs`,应答字节由 `ls_refs_missing_monorepo_path_flush_only` 锁定 | N/A+只读广告默认匿名 | N/A+Monorepo 不存在的路径;ImportRepo 缺失返回 NotFound |
639644
| clone | cargo:integration_git_cli_http_round_trip | cargo:integration_git_ssh_authenticated_clone | cargo:integration_git_cli_auth_anonymous_disabled_rejects_clone | cargo:integration_git_cli_failpath_clone_missing_repo_keeps_service_alive |
640645
| fetch | smoke:HTTP_fetch | smoke:SSH_fetch | N/A+随clone/token往返覆盖 | N/A+Monorepo;ImportRepo见DEFER-GM-01 |
641646
| pull(字面) | cargo:integration_git_cli_http_pull_cl_ref_round_trip | cargo:integration_git_ssh_pull_cl_ref_round_trip | N/A+pull用例内鉴权与HTTP匿名默认 | N/A+定向refs/cl;非默认main快进 |

‎src/ceres/protocol/smart.rs‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -974,7 +974,7 @@ pub fn try_read_pkt_line(bytes: &mut Bytes) -> Result<PktLine, ProtocolError> {
974974

975975
#[cfg(test)]
976976
pub mod test {
977-
use std::{process::Command, time::Duration};
977+
use std::{path::PathBuf, process::Command, time::Duration};
978978

979979
use bytes::{BufMut, Bytes, BytesMut};
980980
use futures::future;
@@ -984,15 +984,22 @@ pub mod test {
984984

985985
use crate::{
986986
callisto::sea_orm_active_enums::RefTypeEnum,
987-
ceres::protocol::{
988-
Capability, ServiceType, SmartSession, TransportProtocol,
989-
import_refs::{CommandType, RefCommand},
990-
smart::{
991-
PKT_LINE_END_MARKER, PktLine, add_pkt_line_string, advertised_capabilities,
992-
read_until_white_space, try_read_pkt_line,
987+
ceres::{
988+
api_service::state::ProtocolApiState,
989+
protocol::{
990+
Capability, ServiceType, SmartSession, TransportProtocol,
991+
import_refs::{CommandType, RefCommand},
992+
smart::{
993+
PKT_LINE_END_MARKER, PktLine, add_pkt_line_string, advertised_capabilities,
994+
read_until_white_space, try_read_pkt_line,
995+
},
993996
},
994997
},
995-
common::{errors::ProtocolError, utils::MEGA_BRANCH_NAME},
998+
common::{
999+
errors::ProtocolError,
1000+
utils::{MEGA_BRANCH_NAME, ZERO_ID},
1001+
},
1002+
jupiter::storage::git_db_storage::fu18_support::{test_cache, wired_storage},
9961003
};
9971004

9981005
#[test]
@@ -1106,6 +1113,36 @@ pub mod test {
11061113
assert_eq!(&pkt_line_stream[..], b"001e# service=git-upload-pack\n000000e87bdc783132575d5b3e78400ace9971970ff43a18 refs/heads/master\0report-status report-status-v2 thin-pack side-band side-band-64k ofs-delta shallow deepen-since deepen-not deepen-relative multi_ack_detailed no-done object-format=sha1\n0000")
11071114
}
11081115

1116+
#[tokio::test]
1117+
async fn v0_empty_repo_pseudo_ref_unchanged() {
1118+
let temp = TempDir::new().unwrap();
1119+
let storage = wired_storage(temp.path()).await;
1120+
let state = ProtocolApiState {
1121+
storage: storage.clone(),
1122+
git_object_cache: test_cache().await,
1123+
entity_store: storage.entity_store(),
1124+
};
1125+
let session = SmartSession::new(
1126+
PathBuf::from("/project/hp16-missing"),
1127+
ServiceType::UploadPack,
1128+
TransportProtocol::Http,
1129+
);
1130+
1131+
let response = session.git_info_refs(&state).await.unwrap();
1132+
let mut expected = BytesMut::new();
1133+
add_pkt_line_string(&mut expected, "# service=git-upload-pack\n".to_owned());
1134+
expected.extend_from_slice(PKT_LINE_END_MARKER);
1135+
add_pkt_line_string(
1136+
&mut expected,
1137+
format!(
1138+
"{ZERO_ID} capabilities^{{}}\0{}\n",
1139+
advertised_capabilities(ServiceType::UploadPack, HashKind::Sha1)
1140+
),
1141+
);
1142+
expected.extend_from_slice(PKT_LINE_END_MARKER);
1143+
assert_eq!(response, expected);
1144+
}
1145+
11091146
#[test]
11101147
pub fn test_add_to_pkt_line() {
11111148
let mut buf = BytesMut::new();

‎src/ceres/protocol/v2.rs‎

Lines changed: 175 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::{
99
api_service::state::ProtocolApiState,
1010
protocol::{
1111
SmartSession,
12+
import_refs::Refs,
1213
smart::{self, PktLine, add_pkt_line_string, try_read_pkt_line},
1314
},
1415
},
@@ -32,6 +33,54 @@ pub fn build_v2_capability_advertisement(hash_kind: HashKind) -> BytesMut {
3233
buf
3334
}
3435

36+
fn encode_ls_refs_response(
37+
head_hash: &str,
38+
git_refs: &[Refs],
39+
ref_prefixes: &[String],
40+
symrefs: bool,
41+
peel: bool,
42+
) -> BytesMut {
43+
let mut buf = BytesMut::new();
44+
45+
if !is_protocol_zero_id(head_hash) {
46+
if symrefs {
47+
add_pkt_line_string(
48+
&mut buf,
49+
format!("{head_hash} HEAD symref-target:refs/heads/main\n"),
50+
);
51+
} else {
52+
add_pkt_line_string(&mut buf, format!("{head_hash} HEAD\n"));
53+
}
54+
}
55+
56+
for git_ref in git_refs {
57+
if !ref_prefixes.is_empty()
58+
&& !ref_prefixes
59+
.iter()
60+
.any(|prefix| git_ref.ref_name.starts_with(prefix))
61+
{
62+
continue;
63+
}
64+
if peel {
65+
add_pkt_line_string(
66+
&mut buf,
67+
format!(
68+
"{} {} peeled:{}\n",
69+
git_ref.ref_hash, git_ref.ref_name, git_ref.ref_hash
70+
),
71+
);
72+
} else {
73+
add_pkt_line_string(
74+
&mut buf,
75+
format!("{} {}\n", git_ref.ref_hash, git_ref.ref_name),
76+
);
77+
}
78+
}
79+
80+
buf.put(Bytes::from_static(smart::PKT_LINE_END_MARKER));
81+
buf
82+
}
83+
3584
pub async fn handle_v2_ls_refs(
3685
session: &SmartSession,
3786
state: &ProtocolApiState,
@@ -67,53 +116,13 @@ pub async fn handle_v2_ls_refs(
67116
let (head_hash, git_refs) = repo_handler.refs_with_head_hash().await?;
68117
session.ensure_advertised_object_ids(&head_hash, &git_refs)?;
69118

70-
let mut buf = BytesMut::new();
71-
72-
if symrefs {
73-
let head_ref = if is_protocol_zero_id(&head_hash) {
74-
"capabilities^{}"
75-
} else {
76-
"HEAD"
77-
};
78-
add_pkt_line_string(
79-
&mut buf,
80-
format!("{head_hash} {head_ref} symref-target:refs/heads/main\n"),
81-
);
82-
} else {
83-
let head_ref = if is_protocol_zero_id(&head_hash) {
84-
"capabilities^{}"
85-
} else {
86-
"HEAD"
87-
};
88-
add_pkt_line_string(&mut buf, format!("{head_hash} {head_ref}\n"));
89-
}
90-
91-
for git_ref in &git_refs {
92-
if !ref_prefixes.is_empty()
93-
&& !ref_prefixes
94-
.iter()
95-
.any(|prefix| git_ref.ref_name.starts_with(prefix))
96-
{
97-
continue;
98-
}
99-
if peel {
100-
add_pkt_line_string(
101-
&mut buf,
102-
format!(
103-
"{} {} peeled:{}\n",
104-
git_ref.ref_hash, git_ref.ref_name, git_ref.ref_hash
105-
),
106-
);
107-
} else {
108-
add_pkt_line_string(
109-
&mut buf,
110-
format!("{} {}\n", git_ref.ref_hash, git_ref.ref_name),
111-
);
112-
}
113-
}
114-
115-
buf.put(Bytes::from_static(smart::PKT_LINE_END_MARKER));
116-
Ok(buf)
119+
Ok(encode_ls_refs_response(
120+
&head_hash,
121+
&git_refs,
122+
&ref_prefixes,
123+
symrefs,
124+
peel,
125+
))
117126
}
118127

119128
/// Response of a protocol v2 `fetch` command.
@@ -398,7 +407,37 @@ pub fn is_v2_upload_pack_request(body: &mut Bytes) -> bool {
398407

399408
#[cfg(test)]
400409
mod tests {
410+
use std::path::PathBuf;
411+
412+
use tempfile::TempDir;
413+
401414
use super::*;
415+
use crate::{
416+
ceres::{
417+
api_service::state::ProtocolApiState,
418+
protocol::{ServiceType, TransportProtocol},
419+
},
420+
common::utils::ZERO_ID,
421+
jupiter::storage::git_db_storage::fu18_support::{test_cache, wired_storage},
422+
};
423+
424+
fn git_ref(name: &str, hash: &str, default_branch: bool) -> Refs {
425+
Refs {
426+
id: 0,
427+
ref_name: name.to_owned(),
428+
ref_hash: hash.to_owned(),
429+
default_branch,
430+
}
431+
}
432+
433+
fn expected_pkt_lines(lines: &[String]) -> BytesMut {
434+
let mut expected = BytesMut::new();
435+
for line in lines {
436+
add_pkt_line_string(&mut expected, line.clone());
437+
}
438+
expected.extend_from_slice(smart::PKT_LINE_END_MARKER);
439+
expected
440+
}
402441

403442
#[test]
404443
fn v2_capability_advertisement_includes_expected_capabilities() {
@@ -450,6 +489,95 @@ mod tests {
450489
assert!(err.to_string().contains("missing command"));
451490
}
452491

492+
#[test]
493+
fn ls_refs_zero_head_flush_only() {
494+
let prefix = ["refs/heads/".to_owned()];
495+
for (ref_prefixes, symrefs, peel) in [
496+
(&[][..], false, false),
497+
(&[][..], true, false),
498+
(&[][..], true, true),
499+
(&prefix[..], false, false),
500+
] {
501+
let response = encode_ls_refs_response(ZERO_ID, &[], ref_prefixes, symrefs, peel);
502+
assert_eq!(&response[..], smart::PKT_LINE_END_MARKER);
503+
}
504+
}
505+
506+
#[test]
507+
fn ls_refs_zero_head_keeps_other_refs() {
508+
let feature = "1111111111111111111111111111111111111111";
509+
let tag = "2222222222222222222222222222222222222222";
510+
let refs = vec![
511+
git_ref("refs/heads/feature", feature, false),
512+
git_ref("refs/tags/v1", tag, false),
513+
];
514+
515+
let response = encode_ls_refs_response(ZERO_ID, &refs, &[], false, false);
516+
let expected = expected_pkt_lines(&[
517+
format!("{feature} refs/heads/feature\n"),
518+
format!("{tag} refs/tags/v1\n"),
519+
]);
520+
assert_eq!(response, expected);
521+
522+
let prefixes = vec!["refs/heads/".to_owned()];
523+
let response = encode_ls_refs_response(ZERO_ID, &refs, &prefixes, true, true);
524+
let expected =
525+
expected_pkt_lines(&[format!("{feature} refs/heads/feature peeled:{feature}\n")]);
526+
assert_eq!(response, expected);
527+
}
528+
529+
#[test]
530+
fn ls_refs_nonzero_head_unchanged() {
531+
let head = "3333333333333333333333333333333333333333";
532+
let feature = "4444444444444444444444444444444444444444";
533+
let refs = vec![
534+
git_ref("refs/heads/main", head, true),
535+
git_ref("refs/heads/feature", feature, false),
536+
];
537+
538+
let with_symrefs = encode_ls_refs_response(head, &refs, &[], true, true);
539+
let expected_with_symrefs = expected_pkt_lines(&[
540+
format!("{head} HEAD symref-target:refs/heads/main\n"),
541+
format!("{head} refs/heads/main peeled:{head}\n"),
542+
format!("{feature} refs/heads/feature peeled:{feature}\n"),
543+
]);
544+
assert_eq!(with_symrefs, expected_with_symrefs);
545+
546+
let without_symrefs = encode_ls_refs_response(head, &refs, &[], false, false);
547+
let expected_without_symrefs = expected_pkt_lines(&[
548+
format!("{head} HEAD\n"),
549+
format!("{head} refs/heads/main\n"),
550+
format!("{feature} refs/heads/feature\n"),
551+
]);
552+
assert_eq!(without_symrefs, expected_without_symrefs);
553+
}
554+
555+
#[tokio::test]
556+
async fn ls_refs_missing_monorepo_path_flush_only() {
557+
let temp = TempDir::new().unwrap();
558+
let storage = wired_storage(temp.path()).await;
559+
let state = ProtocolApiState {
560+
storage: storage.clone(),
561+
git_object_cache: test_cache().await,
562+
entity_store: storage.entity_store(),
563+
};
564+
let session = SmartSession::new(
565+
PathBuf::from("/project/hp16-missing"),
566+
ServiceType::UploadPack,
567+
TransportProtocol::Http,
568+
);
569+
570+
for mut request in [
571+
Bytes::from_static(smart::PKT_LINE_END_MARKER),
572+
Bytes::from_static(b"000csymrefs\n0000"),
573+
] {
574+
let response = handle_v2_ls_refs(&session, &state, &mut request)
575+
.await
576+
.unwrap();
577+
assert_eq!(&response[..], smart::PKT_LINE_END_MARKER);
578+
}
579+
}
580+
453581
#[test]
454582
fn add_packfile_section_header_writes_pkt_line_header() {
455583
let mut buf = BytesMut::new();

0 commit comments

Comments
 (0)