@@ -53,6 +53,21 @@ pub struct ResolveRefResult {
5353 pub oid : String ,
5454}
5555
56+ #[ derive( Debug , Serialize , Deserialize ) ]
57+ #[ serde( rename_all = "camelCase" ) ]
58+ pub struct CommitInfo {
59+ pub oid : String ,
60+ pub message_headline : String ,
61+ pub author_name : String ,
62+ pub author_email : String ,
63+ pub timestamp : i64 ,
64+ }
65+
66+ #[ derive( Debug , Serialize , Deserialize ) ]
67+ pub struct ListCommitsResult {
68+ pub commits : Vec < CommitInfo > ,
69+ }
70+
5671/// Open a git repository and return branch information
5772pub fn open_repo ( path : & str ) -> Result < LoadRepoResult , String > {
5873 let repo = Repository :: open ( path) . map_err ( |e| format ! ( "Failed to open repository: {}" , e) ) ?;
@@ -460,6 +475,65 @@ pub fn read_file_blob(
460475 }
461476}
462477
478+ /// List commits reachable from a given ref, with optional limit
479+ pub fn list_commits (
480+ path : & str ,
481+ ref_name : & str ,
482+ max_count : Option < u32 > ,
483+ ) -> Result < ListCommitsResult , String > {
484+ let repo = Repository :: open ( path) . map_err ( |e| format ! ( "Failed to open repository: {}" , e) ) ?;
485+
486+ let obj = repo
487+ . revparse_single ( ref_name)
488+ . map_err ( |e| format ! ( "Failed to resolve ref '{}': {}" , ref_name, e) ) ?;
489+ let commit = obj
490+ . peel_to_commit ( )
491+ . map_err ( |e| format ! ( "Failed to peel to commit: {}" , e) ) ?;
492+
493+ let mut revwalk = repo
494+ . revwalk ( )
495+ . map_err ( |e| format ! ( "Failed to create revwalk: {}" , e) ) ?;
496+
497+ revwalk
498+ . set_sorting ( git2:: Sort :: TIME )
499+ . map_err ( |e| format ! ( "Failed to set sorting: {}" , e) ) ?;
500+
501+ revwalk
502+ . push ( commit. id ( ) )
503+ . map_err ( |e| format ! ( "Failed to push starting commit: {}" , e) ) ?;
504+
505+ let limit = max_count. unwrap_or ( 200 ) as usize ;
506+ let mut commits = Vec :: with_capacity ( limit. min ( 64 ) ) ;
507+
508+ for oid_result in revwalk {
509+ if commits. len ( ) >= limit {
510+ break ;
511+ }
512+ let oid = oid_result. map_err ( |e| format ! ( "Revwalk error: {}" , e) ) ?;
513+ let c = repo
514+ . find_commit ( oid)
515+ . map_err ( |e| format ! ( "Failed to find commit {}: {}" , oid, e) ) ?;
516+
517+ let message = c. message ( ) . unwrap_or ( "" ) ;
518+ let headline = message. lines ( ) . next ( ) . unwrap_or ( "" ) . to_string ( ) ;
519+
520+ let author = c. author ( ) ;
521+ let author_name = author. name ( ) . unwrap_or ( "Unknown" ) . to_string ( ) ;
522+ let author_email = author. email ( ) . unwrap_or ( "" ) . to_string ( ) ;
523+ let timestamp = author. when ( ) . seconds ( ) ;
524+
525+ commits. push ( CommitInfo {
526+ oid : oid. to_string ( ) ,
527+ message_headline : headline,
528+ author_name,
529+ author_email,
530+ timestamp,
531+ } ) ;
532+ }
533+
534+ Ok ( ListCommitsResult { commits } )
535+ }
536+
463537#[ cfg( test) ]
464538mod tests {
465539 use super :: * ;
@@ -1380,4 +1454,183 @@ mod tests {
13801454 assert_eq ! ( head_result. oid. len( ) , 40 ) ;
13811455 assert ! ( head_result. oid. chars( ) . all( |c| c. is_ascii_hexdigit( ) ) ) ;
13821456 }
1457+
1458+ #[ test]
1459+ fn test_list_commits_returns_commits_for_branch ( ) {
1460+ let ( _tmp, repo_path) = create_test_repo ( ) ;
1461+
1462+ let result = list_commits ( & repo_path, "main" , Some ( 10 ) ) . unwrap ( ) ;
1463+ assert ! (
1464+ !result. commits. is_empty( ) ,
1465+ "main should return at least one commit"
1466+ ) ;
1467+
1468+ let first_commit = & result. commits [ 0 ] ;
1469+ assert_eq ! (
1470+ first_commit. oid. len( ) ,
1471+ 40 ,
1472+ "commit OID should be 40 hex characters, got: {}" ,
1473+ first_commit. oid
1474+ ) ;
1475+ assert ! (
1476+ first_commit. oid. chars( ) . all( |c| c. is_ascii_hexdigit( ) ) ,
1477+ "commit OID should contain only hex characters, got: {}" ,
1478+ first_commit. oid
1479+ ) ;
1480+ assert ! (
1481+ !first_commit. message_headline. trim( ) . is_empty( ) ,
1482+ "message_headline should not be empty"
1483+ ) ;
1484+ assert ! (
1485+ !first_commit. author_name. trim( ) . is_empty( ) ,
1486+ "author_name should not be empty"
1487+ ) ;
1488+ }
1489+
1490+ #[ test]
1491+ fn test_list_commits_respects_max_count ( ) {
1492+ let ( _tmp, repo_path) = create_test_repo ( ) ;
1493+ let repo = git2:: Repository :: open ( & repo_path) . unwrap ( ) ;
1494+ let sig = repo. signature ( ) . unwrap ( ) ;
1495+
1496+ let mut parent_oid = repo
1497+ . revparse_single ( "main" )
1498+ . unwrap ( )
1499+ . peel_to_commit ( )
1500+ . unwrap ( )
1501+ . id ( ) ;
1502+
1503+ for i in 1 ..=3 {
1504+ let file_name = format ! ( "max_count_{}.txt" , i) ;
1505+ let file_path = std:: path:: Path :: new ( & repo_path) . join ( & file_name) ;
1506+ fs:: write ( & file_path, format ! ( "content for commit {}\n " , i) ) . unwrap ( ) ;
1507+
1508+ let mut index = repo. index ( ) . unwrap ( ) ;
1509+ index. add_path ( std:: path:: Path :: new ( & file_name) ) . unwrap ( ) ;
1510+ index. write ( ) . unwrap ( ) ;
1511+ let tree_id = index. write_tree ( ) . unwrap ( ) ;
1512+ let tree = repo. find_tree ( tree_id) . unwrap ( ) ;
1513+ let parent = repo. find_commit ( parent_oid) . unwrap ( ) ;
1514+
1515+ parent_oid = repo
1516+ . commit (
1517+ Some ( "refs/heads/main" ) ,
1518+ & sig,
1519+ & sig,
1520+ & format ! ( "max count commit {}" , i) ,
1521+ & tree,
1522+ & [ & parent] ,
1523+ )
1524+ . unwrap ( ) ;
1525+ }
1526+
1527+ let result = list_commits ( & repo_path, "main" , Some ( 2 ) ) . unwrap ( ) ;
1528+ assert_eq ! (
1529+ result. commits. len( ) ,
1530+ 2 ,
1531+ "list_commits should respect max_count=2"
1532+ ) ;
1533+ }
1534+
1535+ #[ test]
1536+ fn test_list_commits_returns_chronological_order ( ) {
1537+ let ( _tmp, repo_path) = create_test_repo ( ) ;
1538+ let repo = git2:: Repository :: open ( & repo_path) . unwrap ( ) ;
1539+
1540+ let main_commit = repo
1541+ . revparse_single ( "main" )
1542+ . unwrap ( )
1543+ . peel_to_commit ( )
1544+ . unwrap ( ) ;
1545+ repo. branch ( "chrono-order" , & main_commit, false ) . unwrap ( ) ;
1546+ repo. set_head ( "refs/heads/chrono-order" ) . unwrap ( ) ;
1547+ repo. checkout_head ( Some ( git2:: build:: CheckoutBuilder :: new ( ) . force ( ) ) )
1548+ . unwrap ( ) ;
1549+
1550+ let mut parent_oid = main_commit. id ( ) ;
1551+ let commits = vec ! [
1552+ ( "first chronological commit" , 2_500_000_001 ) ,
1553+ ( "second chronological commit" , 2_500_000_002 ) ,
1554+ ( "third chronological commit" , 2_500_000_003 ) ,
1555+ ] ;
1556+
1557+ for ( i, ( message, timestamp) ) in commits. iter ( ) . enumerate ( ) {
1558+ let sig =
1559+ git2:: Signature :: new ( "Test" , "test@test.com" , & git2:: Time :: new ( * timestamp, 0 ) )
1560+ . unwrap ( ) ;
1561+ let file_name = format ! ( "chrono_{}.txt" , i + 1 ) ;
1562+ let file_path = std:: path:: Path :: new ( & repo_path) . join ( & file_name) ;
1563+ fs:: write ( & file_path, format ! ( "{}\n " , message) ) . unwrap ( ) ;
1564+
1565+ let mut index = repo. index ( ) . unwrap ( ) ;
1566+ index. add_path ( std:: path:: Path :: new ( & file_name) ) . unwrap ( ) ;
1567+ index. write ( ) . unwrap ( ) ;
1568+ let tree_id = index. write_tree ( ) . unwrap ( ) ;
1569+ let tree = repo. find_tree ( tree_id) . unwrap ( ) ;
1570+ let parent = repo. find_commit ( parent_oid) . unwrap ( ) ;
1571+
1572+ parent_oid = repo
1573+ . commit (
1574+ Some ( "refs/heads/chrono-order" ) ,
1575+ & sig,
1576+ & sig,
1577+ message,
1578+ & tree,
1579+ & [ & parent] ,
1580+ )
1581+ . unwrap ( ) ;
1582+ }
1583+
1584+ let result = list_commits ( & repo_path, "chrono-order" , Some ( 3 ) ) . unwrap ( ) ;
1585+ assert_eq ! (
1586+ result. commits. len( ) ,
1587+ 3 ,
1588+ "chrono-order should return the three newest commits"
1589+ ) ;
1590+ assert_eq ! (
1591+ result. commits[ 0 ] . message_headline, "third chronological commit" ,
1592+ "newest commit should appear first"
1593+ ) ;
1594+ assert_eq ! (
1595+ result. commits[ 1 ] . message_headline, "second chronological commit" ,
1596+ "second newest commit should appear second"
1597+ ) ;
1598+ assert_eq ! (
1599+ result. commits[ 2 ] . message_headline, "first chronological commit" ,
1600+ "oldest of the created commits should appear last"
1601+ ) ;
1602+ assert ! (
1603+ result. commits[ 0 ] . timestamp >= result. commits[ 1 ] . timestamp
1604+ && result. commits[ 1 ] . timestamp >= result. commits[ 2 ] . timestamp,
1605+ "commit timestamps should be in descending order"
1606+ ) ;
1607+ }
1608+
1609+ #[ test]
1610+ fn test_list_commits_invalid_ref_returns_error ( ) {
1611+ let ( _tmp, repo_path) = create_test_repo ( ) ;
1612+
1613+ let result = list_commits ( & repo_path, "nonexistent-ref" , Some ( 10 ) ) ;
1614+ assert ! (
1615+ result. is_err( ) ,
1616+ "listing commits for a nonexistent ref should return an error"
1617+ ) ;
1618+ let err_msg = result. unwrap_err ( ) ;
1619+ assert ! (
1620+ err_msg. contains( "Failed to resolve" ) ,
1621+ "error should mention resolution failure, got: {}" ,
1622+ err_msg
1623+ ) ;
1624+ }
1625+
1626+ #[ test]
1627+ fn test_list_commits_default_limit_applies ( ) {
1628+ let ( _tmp, repo_path) = create_test_repo ( ) ;
1629+
1630+ let result = list_commits ( & repo_path, "main" , None ) . unwrap ( ) ;
1631+ assert ! (
1632+ !result. commits. is_empty( ) ,
1633+ "list_commits with max_count=None should return commits and not crash"
1634+ ) ;
1635+ }
13831636}
0 commit comments