From aff4147cbc00f8a4c778d8f3fcba14a3b2a9e298 Mon Sep 17 00:00:00 2001 From: vvishuluck Date: Tue, 2 Sep 2025 02:46:02 +0800 Subject: [PATCH 1/3] feat(status): add --porcelain flag for machine-readable output Signed-off-by: vvishuluck --- libra/src/cli.rs | 4 +- libra/src/command/status.rs | 91 ++++++++++++++++++----- libra/src/command/switch.rs | 5 +- libra/tests/command/status_test.rs | 114 +++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 23 deletions(-) diff --git a/libra/src/cli.rs b/libra/src/cli.rs index 89981c433..beb228b69 100644 --- a/libra/src/cli.rs +++ b/libra/src/cli.rs @@ -40,7 +40,7 @@ enum Commands { #[command(about = "Restore working tree files")] Restore(command::restore::RestoreArgs), #[command(about = "Show the working tree status")] - Status, + Status(command::status::StatusArgs), #[command( subcommand, about = "Stash the changes in a dirty working directory away" @@ -151,7 +151,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> { Commands::Add(args) => command::add::execute(args).await, Commands::Rm(args) => command::remove::execute(args).unwrap(), Commands::Restore(args) => command::restore::execute(args).await, - Commands::Status => command::status::execute().await, + Commands::Status(args) => command::status::execute(args).await, Commands::Stash(cmd) => command::stash::execute(cmd).await, Commands::Lfs(cmd) => command::lfs::execute(cmd).await, Commands::Log(args) => command::log::execute(args).await, diff --git a/libra/src/command/status.rs b/libra/src/command/status.rs index 2553a7901..459caadf1 100644 --- a/libra/src/command/status.rs +++ b/libra/src/command/status.rs @@ -11,6 +11,15 @@ use crate::internal::head::Head; use crate::utils::object_ext::{CommitExt, TreeExt}; use crate::utils::{path, util}; use mercury::internal::index::Index; +use std::io::Write; +use clap::Parser; + +#[derive(Parser, Debug, Default)] +pub struct StatusArgs { + /// Output in a machine-readable format + #[clap(long = "porcelain")] + pub porcelain: bool, +} /// path: to workdir #[derive(Debug, Default, Clone)] @@ -41,28 +50,41 @@ impl Changes { * 1. unstaged * 2. staged to be committed */ -pub async fn execute() { + + pub async fn execute_to(args: StatusArgs,writer: &mut impl Write) { if !util::check_repo_exist() { return; } - match Head::current().await { - Head::Detached(commit_hash) => { - println!("HEAD detached at {}", &commit_hash.to_string()[..8]); - } - Head::Branch(branch) => { - println!("On branch {branch}"); + + // Do not output branch info in porcelain mode + if !args.porcelain { + match Head::current().await { + Head::Detached(commit_hash) => { + writeln!(writer, "HEAD detached at {}", &commit_hash.to_string()[..8]).unwrap(); + } + Head::Branch(branch) => { + writeln!(writer, "On branch {branch}").unwrap(); + } } - } - - if Head::current_commit().await.is_none() { - println!("\nNo commits yet\n"); - } + if Head::current_commit().await.is_none() { + writeln!(writer, "\nNo commits yet\n").unwrap(); + } + } + // to cur_dir relative path let staged = changes_to_be_committed().await.to_relative(); let unstaged = changes_to_be_staged().to_relative(); + + // Use machine-readable output in porcelain mode + if args.porcelain { + output_porcelain(&staged, &unstaged, writer); + return; +} + + if staged.is_empty() && unstaged.is_empty() { - println!("nothing to commit, working tree clean"); + writeln!(writer,"nothing to commit, working tree clean").unwrap(); return; } @@ -71,15 +93,15 @@ pub async fn execute() { println!(" use \"libra restore --staged ...\" to unstage"); staged.deleted.iter().for_each(|f| { let str = format!("\tdeleted: {}", f.display()); - println!("{}", str.bright_green()); + writeln!(writer,"{}", str.bright_green()).unwrap(); }); staged.modified.iter().for_each(|f| { let str = format!("\tmodified: {}", f.display()); - println!("{}", str.bright_green()); + writeln!(writer,"{}", str.bright_green()).unwrap(); }); staged.new.iter().for_each(|f| { let str = format!("\tnew file: {}", f.display()); - println!("{}", str.bright_green()); + writeln!(writer,"{}", str.bright_green()).unwrap(); }); } @@ -89,11 +111,11 @@ pub async fn execute() { println!(" use \"libra restore ...\" to discard changes in working directory"); unstaged.deleted.iter().for_each(|f| { let str = format!("\tdeleted: {}", f.display()); - println!("{}", str.bright_red()); + writeln!(writer,"{}", str.bright_red()).unwrap(); }); unstaged.modified.iter().for_each(|f| { let str = format!("\tmodified: {}", f.display()); - println!("{}", str.bright_red()); + writeln!(writer,"{}", str.bright_red()).unwrap(); }); } if !unstaged.new.is_empty() { @@ -101,11 +123,42 @@ pub async fn execute() { println!(" use \"libra add ...\" to include in what will be committed"); unstaged.new.iter().for_each(|f| { let str = format!("\t{}", f.display()); - println!("{}", str.bright_red()); + writeln!(writer,"{}", str.bright_red()).unwrap(); }); } } +pub fn output_porcelain(staged: &Changes, unstaged: &Changes, writer: &mut impl Write) { + // Output changes in the staging area + for file in &staged.new { + writeln!(writer, "A {}", file.display()).unwrap(); + } + for file in &staged.modified { + writeln!(writer, "M {}", file.display()).unwrap(); + } + for file in &staged.deleted { + writeln!(writer, "D {}", file.display()).unwrap(); + } + + // Output unstaged changes + for file in &unstaged.modified { + writeln!(writer, " M {}", file.display()).unwrap(); + } + for file in &unstaged.deleted { + writeln!(writer, " D {}", file.display()).unwrap(); + } + + // Output untracked files + for file in &unstaged.new { + writeln!(writer, "?? {}", file.display()).unwrap(); + } + +} + +pub async fn execute(args: StatusArgs) { + execute_to(args, &mut std::io::stdout()).await +} + /// Check if the working tree is clean pub async fn is_clean() -> bool { let staged = changes_to_be_committed().await; diff --git a/libra/src/command/switch.rs b/libra/src/command/switch.rs index 1ee5d1e47..c235b6c4b 100644 --- a/libra/src/command/switch.rs +++ b/libra/src/command/switch.rs @@ -5,6 +5,7 @@ use super::{ restore::{self, RestoreArgs}, status, }; +use crate::command::status::StatusArgs; use crate::internal::db::get_db_conn_instance; use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext}; use crate::{ @@ -59,11 +60,11 @@ pub async fn execute(args: SwitchArgs) { pub async fn check_status() -> bool { let unstaged: status::Changes = status::changes_to_be_staged(); if !unstaged.deleted.is_empty() || !unstaged.modified.is_empty() { - status::execute().await; + status::execute(StatusArgs::default()).await; eprintln!("fatal: unstaged changes, can't switch branch"); true } else if !status::changes_to_be_committed().await.is_empty() { - status::execute().await; + status::execute(StatusArgs::default()).await; eprintln!("fatal: uncommitted changes, can't switch branch"); true } else { diff --git a/libra/tests/command/status_test.rs b/libra/tests/command/status_test.rs index 47714d80e..201ceaab3 100644 --- a/libra/tests/command/status_test.rs +++ b/libra/tests/command/status_test.rs @@ -1,6 +1,10 @@ use super::*; use std::fs; use std::io::Write; +use libra::command::status::Changes; +use libra::command::status::StatusArgs; +use libra::command::status::execute_to as status_execute; +use libra::command::status::output_porcelain; #[tokio::test] #[serial] /// Tests the file status detection functionality with respect to ignore patterns. @@ -99,3 +103,113 @@ async fn test_changes_to_be_staged() { .iter() .any(|x| x.file_name().unwrap() == "not_ignore.1")); } + + +#[test] +fn test_output_porcelain_format() { + use libra::command::status::Changes; + use std::path::PathBuf; + + // Create test data + let staged = Changes { + new: vec![PathBuf::from("new_file.txt")], + modified: vec![PathBuf::from("modified_file.txt")], + deleted: vec![PathBuf::from("deleted_file.txt")], + }; + + let unstaged = Changes { + new: vec![PathBuf::from("untracked_file.txt")], + modified: vec![PathBuf::from("unstaged_modified.txt")], + deleted: vec![PathBuf::from("unstaged_deleted.txt")], + }; + + // Create a buffer to capture the output + let mut output = Vec::new(); + + // Call the output_porcelain function + output_porcelain(&staged, &unstaged, &mut output); + + // Get the output as a string + let output_str = String::from_utf8(output).unwrap(); + + // Verify the output format + let lines: Vec<&str> = output_str.trim().split('\n').collect(); + + assert!(lines.contains(&"A new_file.txt")); + assert!(lines.contains(&"M modified_file.txt")); + assert!(lines.contains(&"D deleted_file.txt")); + assert!(lines.contains(&" M unstaged_modified.txt")); + assert!(lines.contains(&" D unstaged_deleted.txt")); + assert!(lines.contains(&"?? untracked_file.txt")); +} + + +#[tokio::test] +#[serial] +/// Tests the --porcelain flag for machine-readable output format. +/// Verifies that the output matches Git's porcelain format specification. +async fn test_status_porcelain() { + let test_dir = tempdir().unwrap(); + test::setup_with_new_libra_in(test_dir.path()).await; + let _guard = test::ChangeDirGuard::new(test_dir.path()); + + // Create test data + let mut file1 = fs::File::create("file1.txt").unwrap(); + file1.write_all(b"content").unwrap(); + + let mut file2 = fs::File::create("file2.txt").unwrap(); + file2.write_all(b"content").unwrap(); + + // Add one file to the staging area + add::execute(AddArgs { + pathspec: vec![String::from("file1.txt")], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + refresh: false, + }).await; + + // Add another file to the staging area and modify it + add::execute(AddArgs { + pathspec: vec![String::from("file2.txt")], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + refresh: false, + }).await; + file2.write_all(b"modified content").unwrap(); + + // Create a new file (untracked) + let mut file3 = fs::File::create("file3.txt").unwrap(); + file3.write_all(b"new content").unwrap(); + + // Create a buffer to capture the output + let mut output = Vec::new(); + + // Execute the status command with the --porcelain flag + status_execute(StatusArgs { porcelain: true }, &mut output).await; + + // Get the output as a string + let output_str = String::from_utf8(output).unwrap(); + + // Verify the porcelain output format + let lines: Vec<&str> = output_str.trim().split('\n').collect(); + + // Should contain staged files + assert!(lines.iter().any(|line| line.starts_with("A file1.txt"))); + assert!(lines.iter().any(|line| line.starts_with("A file2.txt"))); + // Should contain modified but unstaged files + assert!(lines.iter().any(|line| line.starts_with(" M file2.txt"))); + + // Should contain untracked files + assert!(lines.iter().any(|line| line.starts_with("?? file3.txt"))); + + // Should not contain human-readable text + assert!(!output_str.contains("Changes to be committed")); + assert!(!output_str.contains("Untracked files")); + assert!(!output_str.contains("On branch")); +} From 1c802717f92d75eec7598620fd779cdd76fda881 Mon Sep 17 00:00:00 2001 From: vvishuluck Date: Tue, 2 Sep 2025 13:02:21 +0800 Subject: [PATCH 2/3] fix(status): remove duplicate references and fix incorrect indentation Signed-off-by: vvishuluck --- libra/src/command/status.rs | 7 +++---- libra/tests/command/status_test.rs | 3 --- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/libra/src/command/status.rs b/libra/src/command/status.rs index 459caadf1..6e8152c26 100644 --- a/libra/src/command/status.rs +++ b/libra/src/command/status.rs @@ -50,7 +50,6 @@ impl Changes { * 1. unstaged * 2. staged to be committed */ - pub async fn execute_to(args: StatusArgs,writer: &mut impl Write) { if !util::check_repo_exist() { return; @@ -78,9 +77,9 @@ impl Changes { // Use machine-readable output in porcelain mode if args.porcelain { - output_porcelain(&staged, &unstaged, writer); - return; -} + output_porcelain(&staged, &unstaged, writer); + return; + } if staged.is_empty() && unstaged.is_empty() { diff --git a/libra/tests/command/status_test.rs b/libra/tests/command/status_test.rs index 201ceaab3..e97237f83 100644 --- a/libra/tests/command/status_test.rs +++ b/libra/tests/command/status_test.rs @@ -1,7 +1,6 @@ use super::*; use std::fs; use std::io::Write; -use libra::command::status::Changes; use libra::command::status::StatusArgs; use libra::command::status::execute_to as status_execute; use libra::command::status::output_porcelain; @@ -104,7 +103,6 @@ async fn test_changes_to_be_staged() { .any(|x| x.file_name().unwrap() == "not_ignore.1")); } - #[test] fn test_output_porcelain_format() { use libra::command::status::Changes; @@ -143,7 +141,6 @@ fn test_output_porcelain_format() { assert!(lines.contains(&"?? untracked_file.txt")); } - #[tokio::test] #[serial] /// Tests the --porcelain flag for machine-readable output format. From f246092620e9b5fef12eb510517acef87830811b Mon Sep 17 00:00:00 2001 From: vvishuluck Date: Wed, 3 Sep 2025 20:41:22 +0800 Subject: [PATCH 3/3] docs(status): update documentation for --porcelain flag Signed-off-by: vvishuluck --- .../docs/libra/command/status/index.mdx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/aria/contents/docs/libra/command/status/index.mdx b/aria/contents/docs/libra/command/status/index.mdx index 56e00f69e..1baaf4569 100644 --- a/aria/contents/docs/libra/command/status/index.mdx +++ b/aria/contents/docs/libra/command/status/index.mdx @@ -5,7 +5,7 @@ description: Show the working tree status ### Usage -libra status +libra status [--porcelain] ### Description @@ -13,7 +13,21 @@ Displays paths that have differences between the index file and the current HEAD tree and the index file, and paths in the working tree that are not tracked by Libra. - `status` command doesn't support any options & arguments yet. + The `status` command now supports the `--porcelain` option for machine-readable output format. See https://git-scm.com/docs/git-status for more information. + +### Options + +- `--porcelain` + Provide output in a stable, easy-to-parse format for scripts. + Each line of output contains two status columns followed by the file path: + First column = index status (changes staged for commit) + Second column = working tree status (changes not staged) + Status codes: + A = Added + M = Modified + D = Deleted + ?? = Untracked + When --porcelain is not provided, the default human-readable output format is used.