Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 127 additions & 27 deletions crates/ctx-cli/src/commands/pack/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use super::*;

pub(crate) fn git_changed_paths(root: &Path) -> Result<std::collections::BTreeSet<String>, String> {
let output = Command::new("git")
.args(["-C", &root.to_string_lossy(), "status", "--porcelain"])
.arg("-C")
.arg(root)
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.output();
let output = match output {
Ok(output) if output.status.success() => output,
Expand All @@ -21,18 +23,37 @@ pub(crate) fn git_changed_paths(root: &Path) -> Result<std::collections::BTreeSe
return Ok(std::collections::BTreeSet::new());
}
};
Ok(parse_git_changed_paths(&output.stdout))
}

fn parse_git_changed_paths(output: &[u8]) -> std::collections::BTreeSet<String> {
let mut changed = std::collections::BTreeSet::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.len() < 4 {
let mut fields = output.split(|byte| *byte == 0);

while let Some(record) = fields.next() {
if record.is_empty() {
continue;
}
if record.len() < 4 || record[2] != b' ' {
continue;
}
let path = line[3..].trim();
let path = path.split(" -> ").last().unwrap_or(path);

let status = &record[..2];
let path = &record[3..];
if !path.is_empty() {
changed.insert(path.replace('\\', "/"));
// The pack walker also uses to_string_lossy for filesystem paths,
// so applying the same conversion keeps non-UTF-8 names comparable.
changed.insert(String::from_utf8_lossy(path).into_owned());
}

// In porcelain v1 -z, rename/copy records put the destination path in
// the main record and the source path in the following NUL field.
if status.iter().any(|byte| matches!(*byte, b'R' | b'C')) {
let _ = fields.next();
}
}
Ok(changed)

changed
}

pub(crate) fn git_diff_entries(
Expand All @@ -43,41 +64,32 @@ pub(crate) fn git_diff_entries(
let (base, head) = parse_diff_revspec(revspec)?;
let before_commit = git_output_in(root, &["rev-parse", "--short=7", base])?;
let after_commit = git_output_in(root, &["rev-parse", "--short=7", head])?;
let name_status = git_output_in(root, &["diff", "--name-status", base, head])?;
let name_status =
git_output_bytes_in(root, &["diff", "--name-status", "-z", base, head, "--"])?;
let mut entries = Vec::new();
for line in name_status.lines() {
// --name-status output is tab-separated; paths may contain spaces.
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 2 {
continue;
}
let status = fields[0];
let (path, before_path) = if status.starts_with('R') && fields.len() >= 3 {
(fields[2], fields[1])
} else {
(fields[1], fields[1])
};
for (status, path, before_path) in parse_git_name_status_z(&name_status) {
let added = status.starts_with('A');
let deleted = status.starts_with('D');
let binary = git_diff_is_binary(root, base, head, path)?;
let patch = git_output_allow_empty(root, &["diff", base, head, "--", path])?;
let binary = git_diff_is_binary(root, base, head, &path)?;
let patch = git_output_allow_empty(root, &["diff", base, head, "--", &path])?;
let mut before_content = if added || binary {
String::new()
} else {
git_show_file(root, base, before_path).unwrap_or_default()
git_show_file(root, base, &before_path).unwrap_or_default()
};
let mut after_content = if deleted || binary {
String::new()
} else {
git_show_file(root, head, path).unwrap_or_default()
git_show_file(root, head, &path).unwrap_or_default()
};
if api_only {
before_content =
extract_public_api_light(path, &before_content).unwrap_or(before_content);
after_content = extract_public_api_light(path, &after_content).unwrap_or(after_content);
extract_public_api_light(&path, &before_content).unwrap_or(before_content);
after_content =
extract_public_api_light(&path, &after_content).unwrap_or(after_content);
}
entries.push(ctx_pack::DiffEntry {
path: path.to_string(),
path,
before_content,
after_content,
before_commit: before_commit.clone(),
Expand All @@ -91,6 +103,50 @@ pub(crate) fn git_diff_entries(
Ok(entries)
}

fn parse_git_name_status_z(output: &[u8]) -> Vec<(String, String, String)> {
let mut fields = output
.split(|byte| *byte == 0)
.filter(|field| !field.is_empty());
let mut entries = Vec::new();

while let Some(status_bytes) = fields.next() {
let status = String::from_utf8_lossy(status_bytes).into_owned();
let Some(before_bytes) = fields.next() else {
break;
};
let before = String::from_utf8_lossy(before_bytes).into_owned();

if matches!(status.as_bytes().first(), Some(b'R' | b'C')) {
let Some(after_bytes) = fields.next() else {
break;
};
let after = String::from_utf8_lossy(after_bytes).into_owned();
entries.push((status, after, before));
} else {
entries.push((status, before.clone(), before));
}
}

entries
}

fn git_output_bytes_in(root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|err| format!("git {}: {err}", args.join(" ")))?;
if !output.status.success() {
return Err(format!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output.stdout)
}

pub(crate) fn parse_diff_revspec(revspec: &str) -> Result<(&str, &str), String> {
let Some((base, head)) = revspec.split_once("..") else {
return Err("diff revspec must be BASE..HEAD".to_string());
Expand Down Expand Up @@ -145,3 +201,47 @@ pub(crate) fn git_output_allow_empty(root: &Path, args: &[&str]) -> Result<Strin
Err(err) => Err(err),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
#[test]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the porcelain status parser test

The second #[test] is attached to the preceding name-status test, causing that test to be registered twice, while porcelain_z_parser_preserves_special_paths_and_rename_target has no test attribute and never runs. Move this attribute to the following function so the changed-path parser actually receives the intended regression coverage.

AGENTS.md reference: AGENTS.md:L41-L42

Useful? React with 👍 / 👎.

fn name_status_z_parser_preserves_special_paths_and_copy_rename_pairs() {
let raw = b"M\0line\nbreak.rs\0R100\0old name.rs\0new -> name.rs\0C075\0source.rs\0copy\\name.rs\0";
let entries = parse_git_name_status_z(raw);

assert_eq!(
entries,
vec![
(
"M".to_string(),
"line\nbreak.rs".to_string(),
"line\nbreak.rs".to_string(),
),
(
"R100".to_string(),
"new -> name.rs".to_string(),
"old name.rs".to_string(),
),
(
"C075".to_string(),
r"copy\name.rs".to_string(),
"source.rs".to_string(),
),
]
);
}

fn porcelain_z_parser_preserves_special_paths_and_rename_target() {
let raw = b"?? dir/a b.rs\0 M literal\\name.rs\0R dst -> literal.rs\0src old.rs\0?? comma,name.rs\0";
let paths = parse_git_changed_paths(raw);

assert!(paths.contains("dir/a b.rs"));
assert!(paths.contains(r"literal\name.rs"));
assert!(paths.contains("dst -> literal.rs"));
assert!(paths.contains("comma,name.rs"));
assert!(!paths.contains("src old.rs"));
}
}
82 changes: 76 additions & 6 deletions crates/ctx-cli/src/commands/pack/timefilter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,46 @@ pub(crate) fn subtract_filter_duration(
}

pub(crate) fn parse_yyyy_mm_dd_utc(input: &str) -> Option<SystemTime> {
let mut parts = input.split('-');
let year = parts.next()?.parse::<i64>().ok()?;
let month = parts.next()?.parse::<u32>().ok()?;
let day = parts.next()?.parse::<u32>().ok()?;
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
let bytes = input.as_bytes();
if bytes.len() != 10
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes
.iter()
.enumerate()
.any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit())
{
return None;
}

let year = input[0..4].parse::<i64>().ok()?;
let month = input[5..7].parse::<u32>().ok()?;
let day = input[8..10].parse::<u32>().ok()?;
let max_day = days_in_month(year, month)?;
if day == 0 || day > max_day {
return None;
}

let days = days_from_civil(year, month, day);
if days < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(days as u64 * 24 * 60 * 60))
let seconds = (days as u64).checked_mul(24 * 60 * 60)?;
UNIX_EPOCH.checked_add(Duration::from_secs(seconds))
}

fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

fn days_in_month(year: i64, month: u32) -> Option<u32> {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
4 | 6 | 9 | 11 => Some(30),
2 if is_leap_year(year) => Some(29),
2 => Some(28),
_ => None,
}
}

pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
Expand All @@ -150,3 +178,45 @@ pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn absolute_date_parser_validates_calendar_dates() {
assert!(parse_yyyy_mm_dd_utc("2024-02-29").is_some());
assert!(parse_yyyy_mm_dd_utc("2026-01-31").is_some());

for invalid in [
"2023-02-29",
"2024-02-30",
"2026-04-31",
"2026-00-10",
"2026-13-10",
"2026-01-00",
] {
assert!(
parse_yyyy_mm_dd_utc(invalid).is_none(),
"{invalid} must be rejected"
);
}
}

#[test]
fn absolute_date_parser_requires_yyyy_mm_dd_shape() {
for invalid in [
"2026-1-01",
"2026-01-1",
"26-01-01",
"20260101",
"2026/01/01",
"9223372036854775807-01-01",
] {
assert!(
parse_yyyy_mm_dd_utc(invalid).is_none(),
"{invalid} must be rejected"
);
}
}
}
Loading