- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse - theme.rs (1075 lines) → 4 files: mod, defaults, seed, create - github.rs (886 lines) → 4 files: mod, api, auth, clone - frontmatter.rs (827 lines) → 3 files: mod, yaml, ops - lib.rs (772 lines) → lib.rs (160) + commands.rs (650) 484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
176 lines
5.4 KiB
Rust
176 lines
5.4 KiB
Rust
use serde::Serialize;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct ModifiedFile {
|
|
pub path: String,
|
|
#[serde(rename = "relativePath")]
|
|
pub relative_path: String,
|
|
pub status: String,
|
|
}
|
|
|
|
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
|
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
let output = Command::new("git")
|
|
.args(["status", "--porcelain", "--untracked-files=all"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("git status failed: {}", stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let files = stdout
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.filter_map(|line| {
|
|
if line.len() < 4 {
|
|
return None;
|
|
}
|
|
let status_code = &line[..2];
|
|
let path = line[3..].trim().to_string();
|
|
|
|
// Only include markdown files
|
|
if !path.ends_with(".md") {
|
|
return None;
|
|
}
|
|
|
|
let status = match status_code.trim() {
|
|
"M" | "MM" | "AM" => "modified",
|
|
"A" => "added",
|
|
"D" => "deleted",
|
|
"??" => "untracked",
|
|
"R" | "RM" => "renamed",
|
|
_ => "modified",
|
|
};
|
|
|
|
let full_path = vault.join(&path).to_string_lossy().to_string();
|
|
|
|
Some(ModifiedFile {
|
|
path: full_path,
|
|
relative_path: path,
|
|
status: status.to_string(),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::git::git_commit;
|
|
use crate::git::tests::setup_git_repo;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
|
|
#[test]
|
|
fn test_get_modified_files() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
|
|
// Create and commit a file
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "note.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Add note"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
// Modify it
|
|
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
|
|
// Add an untracked file
|
|
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
|
|
|
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
|
|
|
assert!(modified.len() >= 2);
|
|
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
|
assert!(statuses.contains(&"modified"));
|
|
assert!(statuses.contains(&"untracked"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_modified_files_untracked_in_subdirectory() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
|
|
// Create initial commit so git is initialized
|
|
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "init.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Initial"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
// Create a new untracked file in a subdirectory (simulates new note creation)
|
|
fs::create_dir_all(vault.join("note")).unwrap();
|
|
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
|
|
|
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
|
|
|
assert_eq!(modified.len(), 1);
|
|
assert_eq!(modified[0].status, "untracked");
|
|
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
|
assert!(
|
|
modified[0].path.ends_with("/note/brand-new.md"),
|
|
"Full path should end with relative path: {}",
|
|
modified[0].path
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_commit_flow_modified_files_then_commit_clears() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
// Create and commit initial file
|
|
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
|
|
git_commit(vp, "initial").unwrap();
|
|
|
|
// Modify the file on disk
|
|
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
|
|
|
|
// get_modified_files should detect the change
|
|
let modified = get_modified_files(vp).unwrap();
|
|
assert!(
|
|
modified.iter().any(|f| f.relative_path == "flow.md"),
|
|
"Modified file should be detected after write"
|
|
);
|
|
|
|
// Commit the change
|
|
let result = git_commit(vp, "update flow").unwrap();
|
|
assert!(
|
|
result.contains("1 file changed") || result.contains("flow.md"),
|
|
"Commit output should reference the changed file: {}",
|
|
result
|
|
);
|
|
|
|
// After commit, get_modified_files should return empty
|
|
let after = get_modified_files(vp).unwrap();
|
|
assert!(
|
|
after.is_empty(),
|
|
"No modified files should remain after commit, found: {:?}",
|
|
after
|
|
);
|
|
}
|
|
}
|