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
18 changes: 16 additions & 2 deletions aria/contents/docs/libra/command/status/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,29 @@ description: Show the working tree status

### Usage

libra status
libra status [--porcelain]

### Description

Displays paths that have differences between the index file and the current HEAD commit, paths that have differences between the working
tree and the index file, and paths in the working tree that are not tracked by Libra.

<Note type="note" title="Note">
`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.
</Note>

### 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.
4 changes: 2 additions & 2 deletions libra/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
90 changes: 71 additions & 19 deletions libra/src/command/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -41,28 +50,40 @@ 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;
}

Expand All @@ -71,15 +92,15 @@ pub async fn execute() {
println!(" use \"libra restore --staged <file>...\" 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();
});
}

Expand All @@ -89,23 +110,54 @@ pub async fn execute() {
println!(" use \"libra restore <file>...\" 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() {
println!("Untracked files:");
println!(" use \"libra add <file>...\" 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;
Expand Down
5 changes: 3 additions & 2 deletions libra/src/command/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions libra/tests/command/status_test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use super::*;
use std::fs;
use std::io::Write;
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.
Expand Down Expand Up @@ -99,3 +102,111 @@ 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"));
}
Loading