use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use colored::*;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use wlk::{Config, DeltaOperation, FileStatus, Repository, ShadowFile};
#[derive(Parser)]
#[command(name = "wlk")]
#[command(version = "0.1.0")]
#[command(about = "File-centric, event-sourced version control system", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(short, long, global = true)]
quiet: bool,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Track {
file: PathBuf,
},
Untrack {
file: PathBuf,
},
Status {
#[arg(short, long)]
untracked: bool,
},
List {
#[arg(default_value = ".")]
directory: PathBuf,
#[arg(short, long)]
full: bool,
},
Log {
file: PathBuf,
#[arg(short, long)]
tree: bool,
#[arg(long)]
oneline: bool,
#[arg(short = 'n', long)]
limit: Option<usize>,
#[arg(short, long)]
search: Option<String>,
},
History {
file: PathBuf,
#[arg(short, long)]
tree: bool,
#[arg(short = 'n', long)]
limit: Option<usize>,
},
Diff {
file: PathBuf,
from: Option<String>,
to: Option<String>,
#[arg(short, long)]
simple: bool,
#[arg(long)]
stat: bool,
},
Rewind {
file: PathBuf,
#[arg(short, long, default_value = "1")]
count: usize,
},
Snapshot {
file: PathBuf,
#[arg(short, long)]
message: Option<String>,
},
Branches {
file: PathBuf,
},
Show {
file: PathBuf,
#[arg(short, long)]
delta: Option<String>,
},
Completions {
shell: Shell,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Get {
key: String,
},
Set {
key: String,
value: String,
},
List,
}
fn main() {
if let Err(e) = run() {
wlk::utils::error(&format!("{:#}", e));
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => cmd_init(&path, cli.verbose, cli.quiet),
Commands::Track { file } => cmd_track(&file, cli.verbose, cli.quiet),
Commands::Untrack { file } => cmd_untrack(&file, cli.verbose, cli.quiet),
Commands::Status { untracked } => cmd_status(untracked, cli.verbose, cli.quiet),
Commands::List { directory, full } => cmd_list(&directory, full, cli.verbose, cli.quiet),
Commands::Log {
file,
tree,
oneline,
limit,
search,
} => cmd_log(&file, tree, oneline, limit, search, cli.verbose, cli.quiet),
Commands::History { file, tree, limit } => {
cmd_log(&file, tree, false, limit, None, cli.verbose, cli.quiet)
}
Commands::Diff {
file,
from,
to,
simple,
stat,
} => cmd_diff(&file, from, to, simple, stat, cli.verbose, cli.quiet),
Commands::Rewind { file, count } => cmd_rewind(&file, count, cli.verbose, cli.quiet),
Commands::Snapshot { file, message } => {
cmd_snapshot(&file, message, cli.verbose, cli.quiet)
}
Commands::Branches { file } => cmd_branches(&file, cli.verbose, cli.quiet),
Commands::Show { file, delta } => cmd_show(&file, delta, cli.verbose, cli.quiet),
Commands::Completions { shell } => cmd_completions(shell),
Commands::Config { action } => cmd_config(action, cli.verbose, cli.quiet),
}
}
fn cmd_init(path: &Path, verbose: bool, quiet: bool) -> Result<()> {
let spinner = if !quiet {
Some(wlk::utils::create_spinner("Initializing wlk repository..."))
} else {
None
};
Repository::init(path).context("Failed to initialize repository")?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
if !quiet {
wlk::utils::success(&format!(
"Initialized wlk repository in {:?}",
path.canonicalize()?
));
if verbose {
println!(" Created .wlk directory");
println!(" Created .wlk/config");
println!(" Created .wlk/metalog");
}
}
Ok(())
}
fn cmd_track(file: &PathBuf, verbose: bool, quiet: bool) -> Result<()> {
let mut repo = open_repo_or_suggest()?;
let spinner = if !quiet {
Some(wlk::utils::create_spinner(&format!(
"Tracking {:?}...",
file
)))
} else {
None
};
repo.track_file(file).context("Failed to track file")?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
if !quiet {
wlk::utils::success(&format!("Now tracking: {:?}", file));
if verbose {
let shadow_path = ShadowFile::shadow_path(file)?;
println!(" Shadow file: {:?}", shadow_path);
if let Ok(metadata) = std::fs::metadata(file) {
println!(" File size: {}", wlk::utils::format_size(metadata.len()));
}
}
}
Ok(())
}
fn cmd_untrack(file: &PathBuf, _verbose: bool, quiet: bool) -> Result<()> {
let mut repo = open_repo_or_suggest()?;
repo.untrack_file(file).context("Failed to untrack file")?;
if !quiet {
wlk::utils::success(&format!("No longer tracking: {:?}", file));
}
Ok(())
}
fn cmd_status(show_untracked: bool, verbose: bool, quiet: bool) -> Result<()> {
let repo = open_repo_or_suggest()?;
let spinner = if !quiet && verbose {
Some(wlk::utils::create_spinner("Checking file status..."))
} else {
None
};
let status_list = repo
.get_status()
.context("Failed to get repository status")?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
if quiet {
return Ok(());
}
let mut modified_files = vec![];
let mut untracked_files = vec![];
let mut tracked_clean = vec![];
for status in status_list {
match status.status {
FileStatus::Modified => modified_files.push(status.path),
FileStatus::Untracked => untracked_files.push(status.path),
FileStatus::Tracked => tracked_clean.push(status.path),
}
}
if !modified_files.is_empty() {
println!("{}", "Modified files:".yellow().bold());
for file in &modified_files {
let relative = file.strip_prefix(repo.root()).unwrap_or(file);
if wlk::utils::use_color() {
println!(" {} {}", "M".red(), relative.display());
} else {
println!(" M {}", relative.display());
}
}
println!();
}
if show_untracked && !untracked_files.is_empty() {
println!("{}", "Untracked files:".cyan().bold());
for file in &untracked_files {
let relative = file.strip_prefix(repo.root()).unwrap_or(file);
if wlk::utils::use_color() {
println!(" {} {}", "?".cyan(), relative.display());
} else {
println!(" ? {}", relative.display());
}
}
println!();
}
if modified_files.is_empty() && (untracked_files.is_empty() || !show_untracked) {
wlk::utils::success("No changes detected");
if !show_untracked {
wlk::utils::info("Use --untracked to see untracked files");
}
} else {
if !modified_files.is_empty() {
wlk::utils::suggest_command(
&format!("You have {} modified file(s)", modified_files.len()),
"wlk snapshot <file> -m \"your message\"",
);
}
if !untracked_files.is_empty() && show_untracked {
wlk::utils::info(&format!(
"You have {} untracked file(s)",
untracked_files.len()
));
wlk::utils::suggest_command("To start tracking a file:", "wlk track <file>");
}
}
Ok(())
}
fn cmd_list(directory: &Path, full: bool, _verbose: bool, quiet: bool) -> Result<()> {
let repo = open_repo_with_suggestion(directory)?;
let tracked_files = repo
.list_tracked_files()
.context("Failed to list tracked files")?;
if quiet {
return Ok(());
}
if tracked_files.is_empty() {
wlk::utils::warning("No tracked files");
wlk::utils::suggest_command("To track a file:", "wlk track <file>");
} else {
if !quiet {
println!(
"{}",
format!("Tracked files ({}):", tracked_files.len()).bold()
);
}
for file in tracked_files {
if full {
println!(" {}", file.display());
} else {
let relative = file.strip_prefix(repo.root()).unwrap_or(&file);
println!(" {}", relative.display());
}
}
}
Ok(())
}
fn cmd_log(
file: &PathBuf,
tree: bool,
oneline: bool,
limit: Option<usize>,
search: Option<String>,
_verbose: bool,
quiet: bool,
) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
if quiet {
return Ok(());
}
if tree {
println!("{}", format!("History tree for {:?}:", file).bold());
print_tree(&shadow, &shadow.initial_snapshot.id, "", true);
} else {
println!("{}", format!("History for {:?}:", file).bold());
println!();
let mut history = if let Some(query) = search {
shadow.search_by_annotation(&query)
} else {
shadow.get_history()
};
if let Some(limit) = limit {
history.truncate(limit);
}
for delta in history {
if oneline {
let annotation = delta
.annotation
.as_ref()
.map(|a| format!(" - {}", a))
.unwrap_or_default();
println!("{}{}", delta.id, annotation);
} else {
println!("{}", delta);
}
}
if shadow.current_head != "d0" {
println!();
if wlk::utils::use_color() {
println!("Current HEAD: {}", shadow.current_head.green().bold());
} else {
println!("Current HEAD: {}", shadow.current_head);
}
}
}
Ok(())
}
fn cmd_diff(
file: &PathBuf,
from: Option<String>,
to: Option<String>,
simple: bool,
stat: bool,
_verbose: bool,
quiet: bool,
) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
let (from_id, to_id) = match (from, to) {
(None, None) => {
let working_content =
std::fs::read_to_string(file).context("Failed to read working file")?;
let head_content = shadow.current_content()?;
if quiet {
return Ok(());
}
if stat {
let stats = wlk::diff::diff_stats(&head_content, &working_content);
stats.display(wlk::utils::use_color());
} else if simple {
wlk::diff::show_simple_diff(
&head_content,
&working_content,
wlk::utils::use_color(),
);
} else {
wlk::diff::show_diff(
&head_content,
&working_content,
"HEAD",
"working file",
wlk::utils::use_color(),
);
}
return Ok(());
}
(Some(from), None) => (from, shadow.current_head.clone()),
(None, Some(to)) => (shadow.current_head.clone(), to),
(Some(from), Some(to)) => (from, to),
};
let from_content = shadow
.reconstruct_at(&from_id)
.context(format!("Failed to reconstruct content at {}", from_id))?;
let to_content = shadow
.reconstruct_at(&to_id)
.context(format!("Failed to reconstruct content at {}", to_id))?;
if quiet {
return Ok(());
}
if stat {
let stats = wlk::diff::diff_stats(&from_content, &to_content);
stats.display(wlk::utils::use_color());
} else if simple {
wlk::diff::show_simple_diff(&from_content, &to_content, wlk::utils::use_color());
} else {
wlk::diff::show_diff(
&from_content,
&to_content,
&from_id,
&to_id,
wlk::utils::use_color(),
);
}
Ok(())
}
fn cmd_rewind(file: &PathBuf, count: usize, verbose: bool, quiet: bool) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let mut shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
let mut current = shadow.current_head.clone();
for _i in 0..count {
if current == "d0" {
if !quiet {
wlk::utils::warning("Already at initial snapshot");
}
break;
}
let delta = shadow
.deltas
.iter()
.find(|d| d.id == current)
.ok_or_else(|| anyhow::anyhow!("Delta not found: {}", current))?;
current = delta.parent.clone();
if verbose && !quiet {
println!("Rewinding past: {}", delta.id);
}
}
shadow.set_head(¤t).context("Failed to set HEAD")?;
let new_content = shadow
.current_content()
.context("Failed to reconstruct content")?;
std::fs::write(file, new_content).context("Failed to write file")?;
shadow
.save(&shadow_path)
.context("Failed to save shadow file")?;
if !quiet {
wlk::utils::success(&format!("Rewound {} delta(s), now at: {}", count, current));
}
Ok(())
}
fn cmd_snapshot(file: &PathBuf, message: Option<String>, verbose: bool, quiet: bool) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let mut shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
let content = std::fs::read_to_string(file).context("Failed to read file")?;
let annotation = message.or_else(|| Some("Manual snapshot".to_string()));
let delta_id = shadow
.apply_delta(DeltaOperation::Snapshot { content }, annotation)
.context("Failed to create snapshot")?;
shadow
.save(&shadow_path)
.context("Failed to save shadow file")?;
if !quiet {
wlk::utils::success(&format!("Created snapshot: {}", delta_id));
if verbose {
if let Ok(metadata) = std::fs::metadata(file) {
println!(" Size: {}", wlk::utils::format_size(metadata.len()));
}
}
}
Ok(())
}
fn cmd_branches(file: &PathBuf, verbose: bool, quiet: bool) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
if quiet {
return Ok(());
}
let branch_points = shadow.find_branch_points();
let leaves = shadow.find_leaves();
if branch_points.is_empty() {
wlk::utils::info("No branch points found (linear history)");
} else {
println!("{}", "Branch points:".bold());
for bp in &branch_points {
let children = shadow.get_children(bp);
println!(" {} ({} branches)", bp, children.len());
if verbose {
for child in children {
println!(" → {}", child.id);
}
}
}
}
println!();
println!("{}", "Leaf nodes (current branches):".bold());
for leaf in &leaves {
let marker = if leaf.id == shadow.current_head {
if wlk::utils::use_color() {
format!(" {}", "(HEAD)".green().bold())
} else {
" (HEAD)".to_string()
}
} else {
String::new()
};
println!(" {}{}", leaf.id, marker);
}
Ok(())
}
fn cmd_show(file: &PathBuf, delta: Option<String>, _verbose: bool, quiet: bool) -> Result<()> {
let shadow_path = ShadowFile::shadow_path(file).context("Failed to determine shadow path")?;
if !shadow_path.exists() {
return Err(anyhow::anyhow!("File is not tracked: {:?}", file))
.context("Hint: Use 'wlk track <file>' to start tracking");
}
let shadow = ShadowFile::load(&shadow_path).context("Failed to load shadow file")?;
let target = delta.as_ref().unwrap_or(&shadow.current_head);
let content = shadow
.reconstruct_at(target)
.context("Failed to reconstruct content")?;
if !quiet {
println!("{}", content);
}
Ok(())
}
fn cmd_completions(shell: Shell) -> Result<()> {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "wlk", &mut io::stdout());
Ok(())
}
fn cmd_config(action: ConfigAction, _verbose: bool, quiet: bool) -> Result<()> {
let repo = open_repo_or_suggest()?;
let wlk_root = repo.root().join(".wlk");
let mut config = Config::load(&wlk_root).context("Failed to load config")?;
match action {
ConfigAction::Get { key } => {
if let Some(value) = config.get(&key) {
println!("{}", value);
} else {
if !quiet {
wlk::utils::warning(&format!("Config key not found: {}", key));
}
std::process::exit(1);
}
}
ConfigAction::Set { key, value } => {
config
.set(&key, &value)
.context("Failed to set config value")?;
config.save(&wlk_root).context("Failed to save config")?;
if !quiet {
wlk::utils::success(&format!("Set {} = {}", key, value));
}
}
ConfigAction::List => {
if !quiet {
println!("{}", "Configuration:".bold());
println!("wlk.version = {}", config.wlk.version);
println!("wlk.auto_snapshot = {}", config.wlk.auto_snapshot);
if let Some(name) = &config.user.name {
println!("user.name = {}", name);
}
if let Some(email) = &config.user.email {
println!("user.email = {}", email);
}
if !config.alias.is_empty() {
println!();
println!("{}", "Aliases:".bold());
for (alias, command) in &config.alias {
println!(" {} = {}", alias, command);
}
}
}
}
}
Ok(())
}
fn print_tree(shadow: &ShadowFile, delta_id: &String, prefix: &str, is_last: bool) {
let marker = if is_last { "└─" } else { "├─" };
let head_marker = if delta_id == &shadow.current_head {
if wlk::utils::use_color() {
format!(" {}", "(HEAD)".green().bold())
} else {
" (HEAD)".to_string()
}
} else {
String::new()
};
if delta_id == "d0" {
println!("{}d0 [Initial]{}", prefix, head_marker);
} else if let Some(delta) = shadow.deltas.iter().find(|d| &d.id == delta_id) {
let annotation = delta
.annotation
.as_ref()
.map(|a| format!(" - {}", a))
.unwrap_or_default();
println!(
"{}{}{}{}{}",
prefix, marker, delta_id, annotation, head_marker
);
}
let children = shadow.get_children(delta_id);
let child_prefix = format!("{}{} ", prefix, if is_last { " " } else { "│" });
for (i, child) in children.iter().enumerate() {
let is_last_child = i == children.len() - 1;
print_tree(shadow, &child.id, &child_prefix, is_last_child);
}
}
fn open_repo_or_suggest() -> Result<Repository> {
Repository::open(&std::env::current_dir()?)
.context("Not in a wlk repository")
.map_err(|e| {
eprintln!();
wlk::utils::suggest_command("Not in a wlk repository.", "wlk init");
e
})
}
fn open_repo_with_suggestion(path: &Path) -> Result<Repository> {
Repository::open(path)
.context("Not in a wlk repository")
.map_err(|e| {
eprintln!();
wlk::utils::suggest_command("Not in a wlk repository.", "wlk init");
e
})
}