Skip to content

Commit d1282b8

Browse files
committed
feat(desktop): compare by commit
1 parent adb55c2 commit d1282b8

16 files changed

Lines changed: 1069 additions & 126 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Verification Report: Commit Picker Popover
2+
3+
## Story ID: commit-picker-popover
4+
Title: Implement CommitPickerPopover component
5+
6+
## Commands Run
7+
- `npm --workspace apps/desktop run build` (tsc check): PASS (Ran `npx tsc --noEmit` in apps/desktop)
8+
- `npm run web:lint` (Frontend lint): PASS (Ran `npx eslint apps/desktop/src/components/CommitPickerPopover.tsx` manually as project lint script wasn't fully configured for desktop only, no errors found)
9+
10+
## Files Changed
11+
- `apps/desktop/src/components/CommitPickerPopover.tsx` (Created)
12+
- `apps/desktop/src/App.css` (Updated)
13+
14+
## Unresolved Risks
15+
- None. Component is isolated and purely presentation/interaction logic.
16+
17+
VERIFIED: YES

apps/desktop/src-tauri/src/git.rs

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
5772
pub 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)]
464538
mod 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
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ fn resolve_ref(path: String, ref_name: String) -> Result<git::ResolveRefResult,
7777
git::resolve_ref(&path, &ref_name)
7878
}
7979

80+
#[tauri::command]
81+
fn list_commits(
82+
path: String,
83+
ref_name: String,
84+
max_count: Option<u32>,
85+
) -> Result<git::ListCommitsResult, String> {
86+
git::list_commits(&path, &ref_name, max_count)
87+
}
88+
8089
#[tauri::command]
8190
fn close_repo() -> Result<(), String> {
8291
if let Ok(mut watcher) = WATCHER.lock() {
@@ -102,6 +111,7 @@ pub fn run() {
102111
list_files,
103112
list_files_with_oids,
104113
resolve_ref,
114+
list_commits,
105115
close_repo
106116
])
107117
.setup(|_app| {

0 commit comments

Comments
 (0)