* ci: auto-release workflow on merge to main Rewrite .github/workflows/release.yml to trigger on every push to main instead of manual tag pushes. The workflow now: - Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER - Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel - Merges them into a universal binary using lipo - Creates a universal .dmg and signed updater tarball - Generates latest.json with per-arch and universal platform entries - Publishes a GitHub Release with auto-generated release notes - Updates a GitHub Pages release history site (gh-pages branch) Product decisions: - Universal binary approach: copy arm64 .app as base, lipo the main executable, keep everything else from arm64 (shared frameworks are architecture-independent). This is the standard Tauri pattern. - Per-arch updater tarballs are also uploaded so the Tauri updater can download the correct arch-specific build (smaller download). - Release notes are auto-generated from git log since last tag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: github pages with release history Use peaceiris/actions-gh-pages@v4 to deploy a release history site. The page fetches releases.json (also deployed) and renders each release with date, notes, and download links for .dmg files. This handles the gh-pages branch creation automatically on first run. The page is available at https://refactoringhq.github.io/laputa-app/ Product decision: used fetch() to load releases.json at runtime instead of inlining it, which is cleaner and avoids shell escaping issues with release note content. The releases.json is deployed alongside index.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: in-app update notification UI Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: updater component tests Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: auto-build-release wireframes Copy ui-design.pen as base. Frames to be added for: 1. Update notification banner (visible state) — horizontal bar at top of app shell with version text, Release Notes link, Update Now button, and dismiss X 2. Update download progress state — spinner icon, progress bar with percentage, downloading text 3. "Restart to apply" state — green accent, version text, Restart Now button Note: Pencil editor was not available during this session. The base design file is committed; frames will be added when the editor is accessible. The implemented component (UpdateBanner.tsx) serves as the source of truth for the design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update ARCHITECTURE.md with release/update system Add comprehensive documentation for: - Release pipeline (4-phase workflow: version → build → release → pages) - Versioning scheme (0.YYYYMMDD.RUN_NUMBER) - Universal binary strategy (lipo merge) - Updater endpoint and latest.json manifest - In-app update UI state machine - GitHub Pages release history site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rustfmt formatting * fix: rustfmt build.rs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
547 lines
17 KiB
Rust
547 lines
17 KiB
Rust
use serde::Serialize;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct GitCommit {
|
|
pub hash: String,
|
|
#[serde(rename = "shortHash")]
|
|
pub short_hash: String,
|
|
pub message: String,
|
|
pub author: String,
|
|
pub date: i64,
|
|
}
|
|
|
|
/// 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);
|
|
let file = Path::new(file_path);
|
|
|
|
let relative = file
|
|
.strip_prefix(vault)
|
|
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
|
|
|
let relative_str = relative
|
|
.to_str()
|
|
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
|
|
|
let output = Command::new("git")
|
|
.args([
|
|
"log",
|
|
"--format=%H|%h|%an|%aI|%s",
|
|
"-n",
|
|
"20",
|
|
"--",
|
|
relative_str,
|
|
])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
// No commits yet is not an error - just return empty history
|
|
if stderr.contains("does not have any commits yet") {
|
|
return Ok(Vec::new());
|
|
}
|
|
return Err(format!("git log failed: {}", stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let commits = stdout
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.filter_map(|line| {
|
|
// Format: hash|short_hash|author|date|message
|
|
// Use splitn(5) so message (last) can contain '|'
|
|
let parts: Vec<&str> = line.splitn(5, '|').collect();
|
|
if parts.len() != 5 {
|
|
return None;
|
|
}
|
|
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
|
.map(|dt| dt.timestamp())
|
|
.unwrap_or(0);
|
|
|
|
Some(GitCommit {
|
|
hash: parts[0].to_string(),
|
|
short_hash: parts[1].to_string(),
|
|
author: parts[2].to_string(),
|
|
date,
|
|
message: parts[4].to_string(),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
/// 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"])
|
|
.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)
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct ModifiedFile {
|
|
pub path: String,
|
|
#[serde(rename = "relativePath")]
|
|
pub relative_path: String,
|
|
pub status: String,
|
|
}
|
|
|
|
/// Get git diff for a specific file.
|
|
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
|
|
let vault = Path::new(vault_path);
|
|
let file = Path::new(file_path);
|
|
|
|
let relative = file
|
|
.strip_prefix(vault)
|
|
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
|
|
|
let relative_str = relative
|
|
.to_str()
|
|
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
|
|
|
// First try tracked file diff
|
|
let output = Command::new("git")
|
|
.args(["diff", "--", relative_str])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
|
|
// If no diff (maybe staged or untracked), try diff --cached
|
|
if stdout.is_empty() {
|
|
let cached = Command::new("git")
|
|
.args(["diff", "--cached", "--", relative_str])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
|
|
|
|
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
|
|
if !cached_stdout.is_empty() {
|
|
return Ok(cached_stdout);
|
|
}
|
|
|
|
// Try showing untracked file as all-new
|
|
let status = Command::new("git")
|
|
.args(["status", "--porcelain", "--", relative_str])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
|
|
|
let status_out = String::from_utf8_lossy(&status.stdout);
|
|
if status_out.starts_with("??") {
|
|
// Untracked file: show entire content as added
|
|
let content =
|
|
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
|
|
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
|
return Ok(format!(
|
|
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
|
relative_str,
|
|
lines.len(),
|
|
lines.join("\n")
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(stdout)
|
|
}
|
|
|
|
/// Get git diff for a specific file at a given commit (compared to its parent).
|
|
pub fn get_file_diff_at_commit(
|
|
vault_path: &str,
|
|
file_path: &str,
|
|
commit_hash: &str,
|
|
) -> Result<String, String> {
|
|
let vault = Path::new(vault_path);
|
|
let file = Path::new(file_path);
|
|
|
|
let relative = file
|
|
.strip_prefix(vault)
|
|
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
|
|
|
let relative_str = relative
|
|
.to_str()
|
|
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
|
|
|
// Show diff between commit^ and commit for this file
|
|
let output = Command::new("git")
|
|
.args([
|
|
"diff",
|
|
&format!("{}^", commit_hash),
|
|
commit_hash,
|
|
"--",
|
|
relative_str,
|
|
])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
|
|
// If diff is empty, it might be the initial commit (no parent).
|
|
// Fall back to showing the full file content as added.
|
|
if stdout.is_empty() {
|
|
let show = Command::new("git")
|
|
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git show: {}", e))?;
|
|
|
|
if show.status.success() {
|
|
let content = String::from_utf8_lossy(&show.stdout);
|
|
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
|
return Ok(format!(
|
|
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
|
relative_str,
|
|
lines.len(),
|
|
lines.join("\n")
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(stdout)
|
|
}
|
|
|
|
/// Commit all changes with a message.
|
|
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
// Stage all changes
|
|
let add = Command::new("git")
|
|
.args(["add", "-A"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
|
|
|
if !add.status.success() {
|
|
let stderr = String::from_utf8_lossy(&add.stderr);
|
|
return Err(format!("git add failed: {}", stderr));
|
|
}
|
|
|
|
// Commit
|
|
let commit = Command::new("git")
|
|
.args(["commit", "-m", message])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
|
|
|
if !commit.status.success() {
|
|
let stderr = String::from_utf8_lossy(&commit.stderr);
|
|
return Err(format!("git commit failed: {}", stderr));
|
|
}
|
|
|
|
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
|
|
}
|
|
|
|
/// Push to remote.
|
|
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
let output = Command::new("git")
|
|
.args(["push"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git push: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("git push failed: {}", stderr));
|
|
}
|
|
|
|
// git push often writes to stderr even on success
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Ok(format!("{}{}", stdout, stderr))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
fn setup_git_repo() -> TempDir {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path();
|
|
|
|
Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(path)
|
|
.output()
|
|
.unwrap();
|
|
|
|
Command::new("git")
|
|
.args(["config", "user.email", "test@test.com"])
|
|
.current_dir(path)
|
|
.output()
|
|
.unwrap();
|
|
|
|
Command::new("git")
|
|
.args(["config", "user.name", "Test User"])
|
|
.current_dir(path)
|
|
.output()
|
|
.unwrap();
|
|
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_history_with_commits() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let file = vault.join("test.md");
|
|
|
|
fs::write(&file, "# Initial\n").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "test.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Initial commit"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "test.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Update test"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
|
|
|
assert_eq!(history.len(), 2);
|
|
assert_eq!(history[0].message, "Update test");
|
|
assert_eq!(history[1].message, "Initial commit");
|
|
assert_eq!(history[0].author, "Test User");
|
|
assert!(!history[0].hash.is_empty());
|
|
assert!(!history[0].short_hash.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_history_no_commits() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let file = vault.join("new.md");
|
|
fs::write(&file, "# New\n").unwrap();
|
|
|
|
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
|
|
|
assert!(history.is_empty());
|
|
}
|
|
|
|
#[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") || statuses.contains(&"untracked"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_diff() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let file = vault.join("diff-test.md");
|
|
|
|
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "diff-test.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Add diff-test"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
|
|
|
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
|
|
|
assert!(!diff.is_empty());
|
|
assert!(diff.contains("-Original content."));
|
|
assert!(diff.contains("+Modified content."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_diff_at_commit() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let file = vault.join("diff-at-commit.md");
|
|
|
|
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "diff-at-commit.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "First commit"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
fs::write(&file, "# First\n\nModified content.").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "diff-at-commit.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Second commit"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
// Get hash of second commit
|
|
let log = Command::new("git")
|
|
.args(["log", "--format=%H", "-1"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
|
|
|
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
|
.unwrap();
|
|
|
|
assert!(!diff.is_empty());
|
|
assert!(diff.contains("-Original content."));
|
|
assert!(diff.contains("+Modified content."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_file_diff_at_initial_commit() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let file = vault.join("initial.md");
|
|
|
|
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
|
Command::new("git")
|
|
.args(["add", "initial.md"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.args(["commit", "-m", "Initial commit"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
let log = Command::new("git")
|
|
.args(["log", "--format=%H", "-1"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
|
|
|
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
|
.unwrap();
|
|
|
|
assert!(!diff.is_empty());
|
|
assert!(diff.contains("+# Initial"));
|
|
assert!(diff.contains("+Hello world."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_commit() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
|
|
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
|
|
|
|
let result = git_commit(vault.to_str().unwrap(), "Test commit");
|
|
assert!(result.is_ok());
|
|
|
|
// Verify the commit exists
|
|
let log = Command::new("git")
|
|
.args(["log", "--oneline", "-1"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
let log_str = String::from_utf8_lossy(&log.stdout);
|
|
assert!(log_str.contains("Test commit"));
|
|
}
|
|
}
|