Skip to content

Commit 543f49e

Browse files
authored
Merge pull request #11 from eugener/develop
2 parents fc399fc + e2681d9 commit 543f49e

File tree

5 files changed

+285
-45
lines changed

5 files changed

+285
-45
lines changed

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,11 @@
105105
- Repository::create_tag_with_options(name, target, options) -> Result<Tag> - create tag with options
106106
- Repository::delete_tag(name) -> Result<()> - delete tag
107107
- Repository::show_tag(name) -> Result<Tag> - detailed tag information
108-
- Tag struct: name, hash, tag_type, message, tagger, timestamp
108+
- Tag struct: name, hash, tag_type, message, tagger (may default), timestamp (may default)
109109
- TagType enum: Lightweight, Annotated
110110
- TagList: Box<[Tag]> with iterator methods (iter, lightweight, annotated), search (find, find_containing, for_commit), counting (len, lightweight_count, annotated_count)
111111
- TagOptions builder: annotated, force, message, sign with builder pattern (with_annotated, with_force, with_message, with_sign)
112-
- Author struct: name, email, timestamp for annotated tag metadata
112+
- Uses unified Author struct from log module for tagger metadata
113113
- **Stash operations**: Complete stash management with type-safe API
114114
- Repository::stash_list() -> Result<StashList> - list all stashes with comprehensive filtering
115115
- Repository::stash_save(message) -> Result<Stash> - create simple stash

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rustic-git"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition = "2024"
55
license = "MIT"
66
description = "A Rustic Git - clean type-safe API over git cli"

src/commands/stash.rs

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
use crate::error::{GitError, Result};
3232
use crate::repository::Repository;
3333
use crate::types::Hash;
34-
use crate::utils::git;
34+
use crate::utils::{git, parse_unix_timestamp};
3535
use chrono::{DateTime, Utc};
3636
use std::fmt;
3737
use std::path::PathBuf;
@@ -226,7 +226,7 @@ impl Repository {
226226
Self::ensure_git()?;
227227

228228
let output = git(
229-
&["stash", "list", "--format=%gd %H %gs"],
229+
&["stash", "list", "--format=%gd %H %ct %gs"],
230230
Some(self.repo_path()),
231231
)?;
232232

@@ -487,19 +487,34 @@ impl Repository {
487487

488488
/// Parse a stash list line into a Stash struct
489489
fn parse_stash_line(index: usize, line: &str) -> Result<Stash> {
490-
// Format: "stash@{0} hash On branch: message"
490+
// Format: "stash@{0} hash timestamp On branch: message"
491491
let parts: Vec<&str> = line.splitn(4, ' ').collect();
492492

493493
if parts.len() < 4 {
494-
return Err(GitError::CommandFailed(
495-
"Invalid stash list format".to_string(),
496-
));
494+
return Err(GitError::CommandFailed(format!(
495+
"Invalid stash list format: expected 4 parts, got {}",
496+
parts.len()
497+
)));
497498
}
498499

499500
let hash = Hash::from(parts[1]);
500501

502+
// Parse timestamp - if it fails, the stash metadata may be corrupted
503+
// Use Unix epoch as fallback to clearly indicate corrupted/invalid timestamp data
504+
let timestamp = parse_unix_timestamp(parts[2]).unwrap_or_else(|_| {
505+
// Timestamp parsing failed - this indicates malformed git stash metadata
506+
// Use Unix epoch (1970-01-01) as fallback to make data corruption obvious
507+
DateTime::from_timestamp(0, 0).unwrap_or_else(Utc::now)
508+
});
509+
501510
// Extract branch name and message from parts[3] (should be "On branch: message")
502511
let remainder = parts[3];
512+
if remainder.is_empty() {
513+
return Err(GitError::CommandFailed(
514+
"Invalid stash format: missing branch and message information".to_string(),
515+
));
516+
}
517+
503518
let (branch, message) = if let Some(colon_pos) = remainder.find(':') {
504519
let branch_part = &remainder[..colon_pos];
505520
let message_part = &remainder[colon_pos + 1..].trim();
@@ -523,7 +538,7 @@ fn parse_stash_line(index: usize, line: &str) -> Result<Stash> {
523538
message,
524539
hash,
525540
branch,
526-
timestamp: Utc::now(), // Simplified for now
541+
timestamp,
527542
})
528543
}
529544

@@ -792,4 +807,68 @@ mod tests {
792807
assert!(display_str.contains("stash@{0}"));
793808
assert!(display_str.contains("Test stash message"));
794809
}
810+
811+
#[test]
812+
fn test_parse_stash_line_invalid_format() {
813+
// Test with insufficient parts
814+
let invalid_line = "stash@{0} abc123"; // Only 2 parts instead of 4
815+
let result = parse_stash_line(0, invalid_line);
816+
817+
assert!(result.is_err());
818+
if let Err(GitError::CommandFailed(msg)) = result {
819+
assert!(msg.contains("Invalid stash list format"));
820+
assert!(msg.contains("expected 4 parts"));
821+
assert!(msg.contains("got 2"));
822+
} else {
823+
panic!("Expected CommandFailed error with specific message");
824+
}
825+
}
826+
827+
#[test]
828+
fn test_parse_stash_line_empty_remainder() {
829+
// Test with empty remainder part
830+
let invalid_line = "stash@{0} abc123 1234567890 "; // Empty 4th part
831+
let result = parse_stash_line(0, invalid_line);
832+
833+
assert!(result.is_err());
834+
if let Err(GitError::CommandFailed(msg)) = result {
835+
assert!(msg.contains("missing branch and message information"));
836+
} else {
837+
panic!("Expected CommandFailed error for empty remainder");
838+
}
839+
}
840+
841+
#[test]
842+
fn test_parse_stash_line_valid_format() {
843+
// Test with valid format
844+
let valid_line = "stash@{0} abc123def456 1234567890 On master: test message";
845+
let result = parse_stash_line(0, valid_line);
846+
847+
assert!(result.is_ok());
848+
let stash = result.unwrap();
849+
assert_eq!(stash.index, 0);
850+
assert_eq!(stash.hash.as_str(), "abc123def456");
851+
assert_eq!(stash.branch, "master");
852+
assert_eq!(stash.message, "test message");
853+
}
854+
855+
#[test]
856+
fn test_parse_stash_line_with_invalid_timestamp() {
857+
// Test stash with invalid timestamp - should still parse but use fallback timestamp
858+
let line_with_invalid_timestamp =
859+
"stash@{0} abc123def456 invalid-timestamp On master: test message";
860+
let result = parse_stash_line(0, line_with_invalid_timestamp);
861+
862+
assert!(result.is_ok());
863+
let stash = result.unwrap();
864+
assert_eq!(stash.index, 0);
865+
assert_eq!(stash.hash.as_str(), "abc123def456");
866+
assert_eq!(stash.branch, "master");
867+
assert_eq!(stash.message, "test message");
868+
869+
// The timestamp should use Unix epoch (1970-01-01) as fallback for invalid data
870+
// Verify fallback timestamp is Unix epoch (indicates data corruption)
871+
assert_eq!(stash.timestamp.timestamp(), 0); // Unix epoch
872+
assert_eq!(stash.timestamp.format("%Y-%m-%d").to_string(), "1970-01-01");
873+
}
795874
}

src/commands/tag.rs

Lines changed: 146 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@
2828
//! # Ok::<(), rustic_git::GitError>(())
2929
//! ```
3030
31+
use crate::commands::log::Author;
3132
use crate::error::{GitError, Result};
3233
use crate::repository::Repository;
3334
use crate::types::Hash;
34-
use crate::utils::git;
35+
use crate::utils::{git, parse_unix_timestamp};
3536
use chrono::{DateTime, Utc};
3637
use std::fmt;
3738

@@ -70,23 +71,6 @@ impl fmt::Display for TagType {
7071
}
7172
}
7273

73-
/// Author information for annotated tags
74-
#[derive(Debug, Clone, PartialEq)]
75-
pub struct Author {
76-
/// Author name
77-
pub name: String,
78-
/// Author email
79-
pub email: String,
80-
/// Author timestamp
81-
pub timestamp: DateTime<Utc>,
82-
}
83-
84-
impl fmt::Display for Author {
85-
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86-
write!(f, "{} <{}>", self.name, self.email)
87-
}
88-
}
89-
9074
/// A collection of tags with efficient iteration and filtering methods
9175
#[derive(Debug, Clone)]
9276
pub struct TagList {
@@ -228,29 +212,31 @@ impl Repository {
228212
pub fn tags(&self) -> Result<TagList> {
229213
Self::ensure_git()?;
230214

231-
// Get list of tag names
232-
let output = git(&["tag", "-l"], Some(self.repo_path()))?;
215+
// Use git for-each-ref to get all tag information in a single call
216+
// Format: refname:short objecttype objectname *objectname taggername taggeremail taggerdate:unix subject body
217+
let output = git(
218+
&[
219+
"for-each-ref",
220+
"--format=%(refname:short)|%(objecttype)|%(objectname)|%(*objectname)|%(taggername)|%(taggeremail)|%(taggerdate:unix)|%(subject)|%(body)",
221+
"refs/tags/",
222+
],
223+
Some(self.repo_path()),
224+
)?;
233225

234226
if output.trim().is_empty() {
235227
return Ok(TagList::new(vec![]));
236228
}
237229

238230
let mut tags = Vec::new();
239231

240-
for tag_name in output.lines() {
241-
let tag_name = tag_name.trim();
242-
if tag_name.is_empty() {
232+
for line in output.lines() {
233+
let line = line.trim();
234+
if line.is_empty() {
243235
continue;
244236
}
245237

246-
// Get tag information
247-
let show_output = git(
248-
&["show", "--format=fuller", tag_name],
249-
Some(self.repo_path()),
250-
)?;
251-
252-
// Parse tag information
253-
if let Ok(tag) = parse_tag_info(tag_name, &show_output) {
238+
// Parse tag information from for-each-ref output
239+
if let Ok(tag) = parse_for_each_ref_line(line) {
254240
tags.push(tag);
255241
}
256242
}
@@ -402,7 +388,86 @@ impl Repository {
402388
}
403389
}
404390

405-
/// Parse tag information from git show output
391+
/// Parse tag information from git for-each-ref output
392+
/// Format: refname:short|objecttype|objectname|*objectname|taggername|taggeremail|taggerdate:unix|subject|body
393+
fn parse_for_each_ref_line(line: &str) -> Result<Tag> {
394+
let parts: Vec<&str> = line.split('|').collect();
395+
396+
if parts.len() < 9 {
397+
return Err(GitError::CommandFailed(format!(
398+
"Invalid for-each-ref format: expected 9 parts, got {}",
399+
parts.len()
400+
)));
401+
}
402+
403+
let name = parts[0].to_string();
404+
let object_type = parts[1];
405+
let object_name = parts[2];
406+
let dereferenced_object = parts[3]; // For annotated tags, this is the commit hash
407+
let tagger_name = parts[4];
408+
let tagger_email = parts[5];
409+
let tagger_date = parts[6];
410+
let subject = parts[7];
411+
let body = parts[8];
412+
413+
// Determine tag type and commit hash
414+
let (tag_type, hash) = if object_type == "tag" {
415+
// Annotated tag - use dereferenced object (the commit it points to)
416+
(TagType::Annotated, Hash::from(dereferenced_object))
417+
} else {
418+
// Lightweight tag - use object name (direct commit reference)
419+
(TagType::Lightweight, Hash::from(object_name))
420+
};
421+
422+
// Build tagger information for annotated tags
423+
let tagger =
424+
if tag_type == TagType::Annotated && !tagger_name.is_empty() && !tagger_email.is_empty() {
425+
// Parse the timestamp - if it fails, the tag metadata may be corrupted
426+
// Use Unix epoch as fallback to clearly indicate corrupted/invalid timestamp data
427+
let timestamp = parse_unix_timestamp(tagger_date).unwrap_or_else(|_| {
428+
// Timestamp parsing failed - this indicates malformed git metadata
429+
// Use Unix epoch (1970-01-01) as fallback to make data corruption obvious
430+
DateTime::from_timestamp(0, 0).unwrap()
431+
});
432+
Some(Author {
433+
name: tagger_name.to_string(),
434+
email: tagger_email.to_string(),
435+
timestamp,
436+
})
437+
} else {
438+
None
439+
};
440+
441+
// Build message for annotated tags
442+
let message = if tag_type == TagType::Annotated && (!subject.is_empty() || !body.is_empty()) {
443+
let full_message = if !body.is_empty() {
444+
format!("{}\n\n{}", subject, body)
445+
} else {
446+
subject.to_string()
447+
};
448+
Some(full_message.trim().to_string())
449+
} else {
450+
None
451+
};
452+
453+
// Timestamp for the tag
454+
let timestamp = if tag_type == TagType::Annotated {
455+
tagger.as_ref().map(|t| t.timestamp)
456+
} else {
457+
None
458+
};
459+
460+
Ok(Tag {
461+
name,
462+
hash,
463+
tag_type,
464+
message,
465+
tagger,
466+
timestamp,
467+
})
468+
}
469+
470+
/// Parse tag information from git show output (fallback method)
406471
fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
407472
let lines: Vec<&str> = show_output.lines().collect();
408473

@@ -483,16 +548,19 @@ fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
483548
})
484549
}
485550

486-
/// Parse author information from a git log line
551+
/// Parse author information from a git tagger line
552+
/// Format: "Tagger: Name <email>" (timestamp not available in this format)
487553
fn parse_author_line(line: &str) -> Option<Author> {
488-
// Parse format: "Name <email> timestamp timezone"
554+
// Parse format: "Name <email>" (no timestamp in git show --format=fuller tagger line)
489555
if let Some(email_start) = line.find('<')
490556
&& let Some(email_end) = line.find('>')
491557
{
492558
let name = line[..email_start].trim().to_string();
493559
let email = line[email_start + 1..email_end].to_string();
494560

495-
// Parse timestamp (simplified - just use current time for now)
561+
// Timestamp is not available in the tagger line from git show --format=fuller
562+
// We use the current time as a fallback, which matches the review feedback
563+
// that tagger timestamp may default
496564
let timestamp = Utc::now();
497565

498566
return Some(Author {
@@ -692,4 +760,47 @@ mod tests {
692760
// Clean up
693761
fs::remove_dir_all(&test_path).unwrap();
694762
}
763+
764+
#[test]
765+
fn test_parse_for_each_ref_line_invalid_format() {
766+
// Test with insufficient parts (should have 9 parts minimum)
767+
let invalid_line = "tag1|commit|abc123"; // Only 3 parts instead of 9
768+
let result = parse_for_each_ref_line(invalid_line);
769+
770+
assert!(result.is_err());
771+
772+
if let Err(GitError::CommandFailed(msg)) = result {
773+
assert!(msg.contains("Invalid for-each-ref format"));
774+
assert!(msg.contains("expected 9 parts"));
775+
assert!(msg.contains("got 3"));
776+
} else {
777+
panic!("Expected CommandFailed error with specific message");
778+
}
779+
}
780+
781+
#[test]
782+
fn test_parse_for_each_ref_line_with_invalid_timestamp() {
783+
// Test annotated tag with invalid timestamp - should still parse but use fallback timestamp
784+
let line_with_invalid_timestamp =
785+
"v1.0.0|tag|abc123|def456|John Doe|[email protected]|invalid-timestamp|Subject|Body";
786+
let result = parse_for_each_ref_line(line_with_invalid_timestamp);
787+
788+
assert!(result.is_ok());
789+
let tag = result.unwrap();
790+
assert_eq!(tag.name, "v1.0.0");
791+
assert_eq!(tag.tag_type, TagType::Annotated);
792+
assert!(tag.tagger.is_some());
793+
794+
// The timestamp should use Unix epoch (1970-01-01) as fallback for invalid data
795+
let tagger = tag.tagger.unwrap();
796+
assert_eq!(tagger.name, "John Doe");
797+
assert_eq!(tagger.email, "[email protected]");
798+
799+
// Verify fallback timestamp is Unix epoch (indicates data corruption)
800+
assert_eq!(tagger.timestamp.timestamp(), 0); // Unix epoch
801+
assert_eq!(
802+
tagger.timestamp.format("%Y-%m-%d").to_string(),
803+
"1970-01-01"
804+
);
805+
}
695806
}

0 commit comments

Comments
 (0)