feat: auto-initialize git repo on Getting Started vault creation (#151)

* feat: auto-initialize git repo on Getting Started vault creation

When a Getting Started vault is created, automatically run git init,
stage all sample files, and create an initial commit so the vault
starts with a clean git history from the very first moment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix rustfmt formatting in mcp.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-28 21:37:23 +01:00
committed by GitHub
parent 9d0c549041
commit f3d0681306
3 changed files with 136 additions and 0 deletions

1
design/gs-git-init.pen Normal file
View File

@@ -0,0 +1 @@
{"children":[{"type":"frame","id":"gs_git_init_after","name":"Getting Started Vault — Git Initialized","x":0,"y":0,"width":600,"height":"fit_content(200)","fill":"#FFFFFF","layout":"vertical","gap":16,"padding":[32,32],"children":[{"type":"text","id":"gs_title","content":"After: Getting Started vault created","fontFamily":"Inter","fontSize":20,"fontWeight":"700","fill":"#111111"},{"type":"text","id":"gs_desc","content":"When a new Getting Started vault is created, it is automatically initialized as a git repo with an initial commit containing all sample files.","fontFamily":"Inter","fontSize":14,"lineHeight":1.5,"fill":"#555555"},{"type":"frame","id":"gs_terminal","name":"Git Log Output","width":"fill_container","height":"fit_content(80)","fill":"#1E1E1E","cornerRadius":[8,8,8,8],"padding":[16,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"gs_prompt","content":"$ git log --oneline","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#AAAAAA"},{"type":"text","id":"gs_log","content":"a1b2c3d Initial vault setup","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#66FF66"}]},{"type":"frame","id":"gs_details","name":"Details","width":"fill_container","layout":"vertical","gap":8,"children":[{"type":"text","id":"gs_detail1","content":"git init + git add . + git commit","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail2","content":"Fallback author: Laputa <vault@laputa.app> (if no global git config)","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail3","content":"No UI changes — backend only (git.rs + getting_started.rs)","fontFamily":"Inter","fontSize":13,"fill":"#333333"}]}]}],"variables":{}}

View File

@@ -12,6 +12,54 @@ pub struct GitCommit {
pub date: i64,
}
/// Initialize a new git repository, stage all files, and create an initial commit.
pub fn init_repo(path: &str) -> Result<(), String> {
let dir = Path::new(path);
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
Ok(())
}
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args[0],
String::from_utf8_lossy(&output.stderr)
))
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
let check = Command::new("git")
.args(["config", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config: {}", e))?;
let value = String::from_utf8_lossy(&check.stdout);
if !check.status.success() || value.trim().is_empty() {
run_git(dir, &["config", key, fallback])?;
}
}
Ok(())
}
/// Get git log history for a specific file in the vault.
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
@@ -562,6 +610,57 @@ mod tests {
dir
}
#[test]
fn test_init_repo_creates_git_directory() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
assert!(vault.join(".git").exists());
}
#[test]
fn test_init_repo_creates_initial_commit() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_init_repo_stages_all_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(vault.join("sub")).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&vault)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"All files should be committed"
);
}
#[test]
fn test_get_file_history_with_commits() {
let dir = setup_git_repo();

View File

@@ -292,6 +292,8 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
crate::git::init_repo(target_path)?;
Ok(vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
@@ -393,4 +395,38 @@ mod tests {
let entries = crate::vault::scan_vault(&vault_path).unwrap();
assert_eq!(entries.len(), SAMPLE_FILES.len());
}
#[test]
fn test_create_getting_started_vault_initializes_git() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("git-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
assert!(vault_path.join(".git").exists());
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(&vault_path)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_create_getting_started_vault_no_untracked_files() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("clean-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&vault_path)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"All files should be committed, no untracked files"
);
}
}