diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6b4021 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --workspace + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Run clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08a85d0..a6ec69b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,10 +39,7 @@ permissions: # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: - pull_request: - push: - tags: - - '**[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: jobs: # Run 'dist plan' (or host) to determine what tasks we need to do diff --git a/Cargo.lock b/Cargo.lock index 196fb6c..04f8c13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.55" @@ -377,6 +386,7 @@ dependencies = [ "anyhow", "base64", "clap", + "clap_complete", "claw-core", "claw-crypto", "claw-git", @@ -496,6 +506,7 @@ name = "claw-policy" version = "0.1.0" dependencies = [ "claw-core", + "globset", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 215ea75..6431677 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ ulid = { version = "1.2", features = ["serde"] } # CLI clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" anyhow = "1.0" dirs = "5.0" diff --git a/FEATURE_GAP_ANALYSIS.md b/FEATURE_GAP_ANALYSIS.md new file mode 100644 index 0000000..0f5565c --- /dev/null +++ b/FEATURE_GAP_ANALYSIS.md @@ -0,0 +1,97 @@ +# Claw Feature Gap Analysis + +Based on a thorough review of every crate, CLI command, test, and CI workflow in the repository. + +--- + +## Part 1: Missing Features Users Would Reasonably Expect + +### Tier 1 — High Impact + +| # | Gap | What's Missing | Why Users Expect It | Complexity | +|---|-----|---------------|---------------------|------------| +| 1 | **No `claw stash` or undo mechanism** | There is no way to temporarily shelve uncommitted work or undo a snapshot. Git users rely on `stash` and `reset` constantly. The reflog exists but is not exposed via CLI. | Any user switching branches mid-work will lose changes or be blocked by the "uncommitted changes" guard in `checkout`. | Medium | +| 2 | **No `claw tag` command** | No way to create named, immutable markers for releases. Tags are fundamental VCS primitive and the README mentions releases but provides no tagging mechanism. | Users shipping software need release markers (v1.0.0, etc.). | Low | +| 3 | **No `claw reflog` command** | Reflog infrastructure exists (`claw-store/src/reflog.rs` — append/read implemented with tests), but there is no CLI command to view it. `claw show` can display RefLog objects, but there's no `claw reflog` subcommand. | Safety net for recovering from mistakes — critical for adoption confidence. | Low | +| 4 | **Policy `sensitive_paths` not enforced** | Policies store `sensitive_paths`, `quarantine_lane`, and `min_trust_score` fields, but `claw-policy/src/evaluator.rs` only checks visibility and required_checks. These three fields are write-only decorations. | The README advertises sensitive-path gating. Users creating policies with `--sensitive-paths` expect them to be enforced during integration. | Medium | +| 5 | **No `claw workstream` CLI command** | Workstream objects exist in the type system and are served via gRPC (`WorkstreamServiceServer`), but there is no CLI command to create, list, or manage workstreams. Changes have a `workstream_id` field that is always `None`. | The README lists Workstream as a first-class object type. Users reading the docs expect to use it. | Medium | +| 6 | **No CI/test workflow in GitHub Actions** | `.github/workflows/release.yml` handles release builds only. There is no workflow that runs `cargo test`, `cargo clippy`, or `cargo fmt --check` on PRs or pushes. | Contributors expect CI to validate their changes. The project has 10+ integration tests and 27 unit test modules with no automated gating. | Low | +| 7 | **No `claw clone` top-level command** | Clone is buried as `claw sync clone`. Every VCS user expects `claw clone ` at the top level. | Discoverability — the first command a new user runs after install. | Low | +| 8 | **`claw diff` has no color output** | `diff_render.rs` emits plain unified diff. No ANSI color support for terminals. | Every modern diff tool colorizes additions/deletions. Without it, diffs are hard to scan. | Low | + +### Tier 2 — Medium Impact + +| # | Gap | What's Missing | Why Users Expect It | Complexity | +|---|-----|---------------|---------------------|------------| +| 9 | **No garbage collection / prune** | Objects are stored forever. No `claw gc` to remove unreachable objects, compact loose objects into packs, or reclaim disk space. | Repositories grow without bound. Pack files exist (`claw-store/src/pack.rs`) but are only used for sync transport, not local compaction. | High | +| 10 | **No `claw blame` / annotate** | No way to trace line-by-line authorship. Particularly important for Claw's thesis (who wrote this — human or agent?). | Claw's core value proposition is provenance. Not being able to answer "who wrote this line?" undermines the pitch. | High | +| 11 | **No interactive conflict resolution guidance** | `claw resolve` detects conflict markers but provides no interactive merge tool integration or 3-way diff visualization. | Users encountering merge conflicts need more than "edit the file manually." | Medium | +| 12 | **Pack file delta compression missing** | `pack.rs` header comment: "MVP - no delta compression." Every object is stored full-size in packs. | Network transfer and storage efficiency will be poor at scale compared to Git's delta chains. | High | +| 13 | **No `claw config` command** | `RepoConfig` (`claw-store/src/repo.rs`) has only `version` and `name`. No CLI to set/get configuration. No user-level config (~/.clawconfig). | Users expect to configure author name, default remote, editor, diff tool, etc. | Medium | +| 14 | **No file-level add/remove tracking** | `claw snapshot` captures everything atomically (by design), but there's no `claw add` or `claw rm` for selective tracking. While intentional, users migrating from Git will look for this. | README acknowledges "no staging area — by design" but offers no selective alternative (e.g., `claw snapshot --paths`). | Medium | +| 15 | **Restricted visibility is identical to Private** | `claw-policy/src/visibility.rs:16-21` — Restricted just checks for encrypted_private, same as Private. The comment says "In MVP." | If the options exist, users expect them to behave differently. | Medium | +| 16 | **No `claw cherry-pick` or patch extraction** | Can't apply individual revisions from one branch to another without full merge. | Common workflow for hotfixes and selective backporting. | High | + +### Tier 3 — Nice to Have + +| # | Gap | What's Missing | Why Users Expect It | Complexity | +|---|-----|---------------|---------------------|------------| +| 17 | **No TOML/YAML codec** | Registry maps `.toml` and `.yaml` to the text/line codec. The README mentions "The architecture supports adding codecs for YAML, TOML" but none exist. | Structural merges for config files would demonstrate the codec advantage over Git. | Medium | +| 18 | **Event service uses polling, not filesystem watches** | `event_service.rs:37-39` comment: "A production implementation would use filesystem watches." Currently polls every 2 seconds. | Agent integrations expecting real-time events will experience latency and unnecessary CPU usage. | Medium | +| 19 | **No hook system** | Git has pre-commit, post-commit, pre-push hooks. Claw has no equivalent. | Developers expect to run linters, formatters, and custom scripts at lifecycle points. | Medium | +| 20 | **No `claw search` / intent query** | Can list intents but can't search by goal text, status filter, or date range from CLI. | With structured intents, users expect structured queries. | Low | + +--- + +## Part 2: Developer Experience Improvements + +### Tier 1 — High Impact + +| # | Gap | Current State | Improvement | Complexity | +|---|-----|--------------|-------------|------------| +| 1 | **No CI on PRs** | Only `release.yml` exists. | Add `ci.yml` with `cargo test --workspace`, `cargo clippy --workspace -- -D warnings`, `cargo fmt -- --check`. | Low | +| 2 | **Sparse integration tests** | One test file (`tests/integration/spec_tests.rs`) with 10 tests. No CLI-level end-to-end tests. | Add CLI integration tests that invoke the `claw` binary and verify stdout/stderr/exit codes for common workflows. | Medium | +| 3 | **No error context in anyhow chains** | Commands use `anyhow::Result` but many call sites don't add `.context()`. Errors like "No such file" give no indication of which file or which operation. | Add `.context("loading HEAD revision")` style annotations at key points. | Low | +| 4 | **`--json` output inconsistent** | `claw log` and `claw status` support `--json`. Other commands (intent list, change list, branch list, show, policy list) don't. | Add `--json` to all listing and inspection commands for scriptability. | Medium | +| 5 | **No shell completions** | Clap supports generating shell completions (`clap_complete`). Not wired up. | Add `claw completions ` command generating bash/zsh/fish completions. | Low | + +### Tier 2 — Medium Impact + +| # | Gap | Current State | Improvement | Complexity | +|---|-----|--------------|-------------|------------| +| 6 | **No progress indicators for sync** | `claw sync push/pull/clone` print final result but nothing during transfer. | Add object count / bytes transferred progress bars for large repos. | Medium | +| 7 | **`claw init` doesn't create `.clawignore`** | Users must know to create it manually. | Generate a default `.clawignore` (like `.gitignore`) with common patterns (target/, node_modules/, .env, etc.). | Low | +| 8 | **No `--verbose` / `--quiet` global flags** | Tracing subscriber is set up but not controllable from CLI. | Add `-v`/`-q` global flags mapped to tracing levels. | Low | +| 9 | **No `claw help ` for learning** | `--help` is clap-generated and terse. | Add `claw help intents`, `claw help capsules` etc. for conceptual documentation. | Low | +| 10 | **`.clawignore` doesn't support negation** | Glob patterns only. No `!pattern` for re-inclusion. No nested `.clawignore` files in subdirectories. | Support negation patterns and recursive ignore files like `.gitignore`. | Medium | +| 11 | **No property-based tests** | `proptest` is a workspace dependency but not used anywhere. | Add proptest-based fuzzing for COF encode/decode round-trips, patch apply/invert symmetry, and commute correctness. | Medium | +| 12 | **Daemon has no graceful shutdown** | `claw daemon` runs forever with no signal handling. | Handle SIGTERM/SIGINT for clean shutdown, flush pending writes. | Low | +| 13 | **No benchmarks** | No `benches/` directory. No performance baselines for core operations (hashing, COF encode/decode, tree diff, patch apply). | Add criterion benchmarks for hot paths. | Medium | + +### Tier 3 — Nice to Have + +| # | Gap | Current State | Improvement | Complexity | +|---|-----|--------------|-------------|------------| +| 14 | **Pack read is not mmap'd** | `read_object_from_pack` calls `std::fs::read` on the entire pack file every time. | Use memory-mapped I/O for pack reads to avoid repeated full-file loads. | Medium | +| 15 | **No LSP / editor integration** | No VS Code extension, no tree-sitter grammar, no semantic tokens. | Provide at minimum a VS Code extension for intent/change/status sidebar. | High | +| 16 | **Daemon stdio mode only supports `hello` and `refs`** | JSON-RPC methods are minimal. | Expand stdio protocol to cover intent CRUD, snapshot, and diff operations for embedded agent use. | Medium | +| 17 | **No man pages** | Only `--help` output. No `man claw` or `man claw-intent`. | Generate man pages from clap definitions during build. | Low | + +--- + +## Summary: Top 10 by Impact-to-Complexity Ratio + +Ranked by (user impact / implementation complexity), where items that deliver the most value for the least effort come first: + +| Rank | Item | Category | Impact | Complexity | +|------|------|----------|--------|------------| +| 1 | Add CI workflow (test + clippy + fmt) | DX | High | Low | +| 2 | Add `claw reflog` CLI command | Feature | High | Low | +| 3 | Add `claw tag` command | Feature | High | Low | +| 4 | Add `claw clone` as top-level alias | Feature | High | Low | +| 5 | Add shell completions (`claw completions`) | DX | High | Low | +| 6 | Colorized diff output | DX | Medium | Low | +| 7 | Enforce `sensitive_paths` in policy evaluator | Feature | High | Medium | +| 8 | Add `--json` output to all list/show commands | DX | High | Medium | +| 9 | Add `claw stash` (save/pop/list) | Feature | High | Medium | +| 10 | Add `claw workstream` CLI commands | Feature | Medium | Medium | diff --git a/crates/claw-policy/Cargo.toml b/crates/claw-policy/Cargo.toml index 23226dd..8838d31 100644 --- a/crates/claw-policy/Cargo.toml +++ b/crates/claw-policy/Cargo.toml @@ -6,4 +6,5 @@ license.workspace = true [dependencies] claw-core = { workspace = true } +globset = { workspace = true } thiserror = { workspace = true } diff --git a/crates/claw-policy/src/evaluator.rs b/crates/claw-policy/src/evaluator.rs index ff64028..0f6f4d5 100644 --- a/crates/claw-policy/src/evaluator.rs +++ b/crates/claw-policy/src/evaluator.rs @@ -1,12 +1,13 @@ use claw_core::types::{Capsule, Policy, Revision}; use crate::checks::verify_required_checks; +use crate::sensitive_paths::check_sensitive_paths; use crate::visibility::check_visibility; use crate::PolicyError; pub fn evaluate_policy( policy: &Policy, - _revision: &Revision, + revision: &Revision, capsule: &Capsule, ) -> Result<(), PolicyError> { // Check visibility constraints @@ -15,5 +16,8 @@ pub fn evaluate_policy( // Check required checks verify_required_checks(policy, capsule)?; + // Check sensitive path restrictions + check_sensitive_paths(policy, revision, capsule)?; + Ok(()) } diff --git a/crates/claw-policy/src/lib.rs b/crates/claw-policy/src/lib.rs index 167d01e..7d9e245 100644 --- a/crates/claw-policy/src/lib.rs +++ b/crates/claw-policy/src/lib.rs @@ -1,6 +1,7 @@ pub mod checks; pub mod error; pub mod evaluator; +pub mod sensitive_paths; pub mod visibility; pub use error::PolicyError; diff --git a/crates/claw-policy/src/sensitive_paths.rs b/crates/claw-policy/src/sensitive_paths.rs new file mode 100644 index 0000000..995cf5b --- /dev/null +++ b/crates/claw-policy/src/sensitive_paths.rs @@ -0,0 +1,72 @@ +use claw_core::types::{Capsule, Policy, Revision}; + +use crate::PolicyError; + +/// Check that revisions touching sensitive paths have adequate capsule evidence. +/// +/// If a policy declares `sensitive_paths` globs and the revision's patches touch +/// any matching path, the capsule must carry a passing "sensitive-path-review" +/// evidence item (or the agent must have encrypted private fields, proving +/// elevated trust). +pub fn check_sensitive_paths( + policy: &Policy, + revision: &Revision, + capsule: &Capsule, +) -> Result<(), PolicyError> { + if policy.sensitive_paths.is_empty() { + return Ok(()); + } + + // Build glob matchers for each sensitive path pattern + let mut builder = globset::GlobSetBuilder::new(); + for pattern in &policy.sensitive_paths { + let glob = globset::Glob::new(pattern).map_err(|e| { + PolicyError::Violation(format!("invalid sensitive_paths glob '{}': {}", pattern, e)) + })?; + builder.add(glob); + } + let glob_set = builder.build().map_err(|e| { + PolicyError::Violation(format!("failed to compile sensitive_paths globs: {}", e)) + })?; + + // Extract touched paths from revision's policy_evidence field + // (policy_evidence carries "touched:" entries set by the snapshot command) + let touched_paths: Vec<&str> = revision + .policy_evidence + .iter() + .filter_map(|e| e.strip_prefix("touched:")) + .collect(); + + // If no touched paths recorded, we can't enforce — skip + if touched_paths.is_empty() { + return Ok(()); + } + + let any_match = touched_paths.iter().any(|path| glob_set.is_match(path)); + if !any_match { + return Ok(()); + } + + // A sensitive path was touched — require evidence + let has_review = capsule + .public_fields + .evidence + .iter() + .any(|e| e.name == "sensitive-path-review" && e.status == "pass"); + + let has_encrypted_private = capsule.encrypted_private.is_some(); + + if !has_review && !has_encrypted_private { + let matched: Vec<&str> = touched_paths + .iter() + .filter(|p| glob_set.is_match(*p)) + .copied() + .collect(); + return Err(PolicyError::Violation(format!( + "revision touches sensitive path(s) [{}] but capsule lacks 'sensitive-path-review' evidence", + matched.join(", ") + ))); + } + + Ok(()) +} diff --git a/crates/claw/Cargo.toml b/crates/claw/Cargo.toml index c83a37e..9926e46 100644 --- a/crates/claw/Cargo.toml +++ b/crates/claw/Cargo.toml @@ -29,6 +29,7 @@ claw-sync = { workspace = true } claw-git = { workspace = true } tonic = { workspace = true } clap = { workspace = true } +clap_complete = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } diff --git a/crates/claw/src/commands/branch.rs b/crates/claw/src/commands/branch.rs index 4d36145..f8f58ee 100644 --- a/crates/claw/src/commands/branch.rs +++ b/crates/claw/src/commands/branch.rs @@ -8,6 +8,9 @@ use crate::config::find_repo_root; pub struct BranchArgs { #[command(subcommand)] command: Option, + /// Output as JSON (for branch listing) + #[arg(long)] + json: bool, } #[derive(Subcommand)] @@ -38,7 +41,21 @@ pub fn run(args: BranchArgs) -> anyhow::Result<()> { }; let refs = store.list_refs("heads/")?; - if refs.is_empty() { + if args.json { + let entries: Vec = refs + .iter() + .map(|(name, id)| { + let short_name = name.strip_prefix("heads/").unwrap_or(name); + let is_current = current_branch.as_deref() == Some(name.as_str()); + serde_json::json!({ + "name": short_name, + "current": is_current, + "target": id.to_hex(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&entries)?); + } else if refs.is_empty() { // Show default branch even if no refs exist if let Some(ref branch) = current_branch { let name = branch.strip_prefix("heads/").unwrap_or(branch); diff --git a/crates/claw/src/commands/change.rs b/crates/claw/src/commands/change.rs index e19f3cb..f1e8f37 100644 --- a/crates/claw/src/commands/change.rs +++ b/crates/claw/src/commands/change.rs @@ -32,6 +32,9 @@ enum ChangeCommand { /// Filter by intent ID #[arg(short, long)] intent: Option, + /// Output as JSON + #[arg(long)] + json: bool, }, /// Update change status Status { @@ -108,19 +111,43 @@ pub fn run(args: ChangeArgs) -> anyhow::Result<()> { println!(" Head revision: {:?}", change.head_revision); } } - ChangeCommand::List { intent } => { + ChangeCommand::List { intent, json } => { let root = find_repo_root()?; let store = ClawStore::open(&root)?; let refs = store.list_refs("changes")?; - for (_, id) in &refs { - if let Ok(Object::Change(change)) = store.load_object(id) { - let matches_intent = intent - .as_ref() - .map(|filter| change.intent_id.to_string() == *filter) - .unwrap_or(true); - if !matches_intent { - continue; + let changes: Vec<_> = refs + .iter() + .filter_map(|(_, id)| { + if let Ok(Object::Change(change)) = store.load_object(id) { + let matches = intent + .as_ref() + .map(|filter| change.intent_id.to_string() == *filter) + .unwrap_or(true); + if matches { + Some(change) + } else { + None + } + } else { + None } + }) + .collect(); + if json { + let entries: Vec = changes + .iter() + .map(|c| { + serde_json::json!({ + "id": c.id.to_string(), + "intent_id": c.intent_id.to_string(), + "status": format!("{:?}", c.status), + "head_revision": c.head_revision.map(|r| r.to_hex()), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + for change in &changes { println!( "{} {:?} intent:{}", change.id, change.status, change.intent_id diff --git a/crates/claw/src/commands/completions.rs b/crates/claw/src/commands/completions.rs new file mode 100644 index 0000000..491b583 --- /dev/null +++ b/crates/claw/src/commands/completions.rs @@ -0,0 +1,14 @@ +use clap::Args; +use clap::CommandFactory; +use clap_complete::{generate, Shell}; + +#[derive(Args)] +pub struct CompletionsArgs { + /// Shell to generate completions for + shell: Shell, +} + +pub fn run(args: CompletionsArgs) { + let mut cmd = crate::Cli::command(); + generate(args.shell, &mut cmd, "claw", &mut std::io::stdout()); +} diff --git a/crates/claw/src/commands/intent.rs b/crates/claw/src/commands/intent.rs index 509af51..7d9d522 100644 --- a/crates/claw/src/commands/intent.rs +++ b/crates/claw/src/commands/intent.rs @@ -31,7 +31,11 @@ enum IntentCommand { id: String, }, /// List intents - List, + List { + /// Output as JSON + #[arg(long)] + json: bool, + }, /// Update an intent Update { /// Intent ID (ULID) @@ -115,11 +119,28 @@ pub fn run(args: IntentArgs) -> anyhow::Result<()> { println!(" Goal: {}", intent.goal); } } - IntentCommand::List => { + IntentCommand::List { json } => { let root = find_repo_root()?; let store = ClawStore::open(&root)?; let refs = store.list_refs("intents")?; - if refs.is_empty() { + if json { + let entries: Vec = refs + .iter() + .filter_map(|(_, id)| { + if let Ok(Object::Intent(intent)) = store.load_object(id) { + Some(serde_json::json!({ + "id": intent.id.to_string(), + "title": intent.title, + "status": format!("{:?}", intent.status), + "goal": intent.goal, + })) + } else { + None + } + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&entries)?); + } else if refs.is_empty() { println!("No intents found."); } else { for (_name, id) in &refs { diff --git a/crates/claw/src/commands/mod.rs b/crates/claw/src/commands/mod.rs index 85e985b..55fe1bc 100644 --- a/crates/claw/src/commands/mod.rs +++ b/crates/claw/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod branch; pub mod change; pub mod checkout; +pub mod completions; pub mod daemon; pub mod diff; pub mod git_export; @@ -13,13 +14,17 @@ pub mod intent; pub mod log; pub mod patch; pub mod policy; +pub mod reflog; pub mod remote; pub mod resolve; pub mod ship; pub mod show; pub mod snapshot; +pub mod stash; pub mod status; pub mod sync; +pub mod tag; +pub mod workstream; use clap::Subcommand; @@ -71,6 +76,18 @@ pub enum Commands { Remote(remote::RemoteArgs), /// Authenticate with ClawLab remotes Auth(auth::AuthArgs), + /// View the reflog for a branch + Reflog(reflog::ReflogArgs), + /// Manage tags + Tag(tag::TagArgs), + /// Clone a remote repository + Clone(sync::CloneArgs), + /// Stash working tree changes + Stash(stash::StashArgs), + /// Manage workstreams + Workstream(workstream::WorkstreamArgs), + /// Generate shell completions + Completions(completions::CompletionsArgs), } impl Commands { @@ -99,6 +116,15 @@ impl Commands { Commands::Resolve(args) => resolve::run(args), Commands::Remote(args) => remote::run(args), Commands::Auth(args) => auth::run(args).await, + Commands::Reflog(args) => reflog::run(args), + Commands::Tag(args) => tag::run(args), + Commands::Clone(args) => sync::run_clone(args).await, + Commands::Stash(args) => stash::run(args), + Commands::Workstream(args) => workstream::run(args), + Commands::Completions(args) => { + completions::run(args); + Ok(()) + } } } } diff --git a/crates/claw/src/commands/policy.rs b/crates/claw/src/commands/policy.rs index 255a4ae..5164854 100644 --- a/crates/claw/src/commands/policy.rs +++ b/crates/claw/src/commands/policy.rs @@ -44,7 +44,11 @@ enum PolicyCommand { id: String, }, /// List policies - List, + List { + /// Output as JSON + #[arg(long)] + json: bool, + }, } pub fn run(args: PolicyArgs) -> anyhow::Result<()> { @@ -127,11 +131,33 @@ pub fn run(args: PolicyArgs) -> anyhow::Result<()> { println!(" Min trust score: {score}"); } } - PolicyCommand::List => { + PolicyCommand::List { json } => { let root = find_repo_root()?; let store = ClawStore::open(&root)?; let refs = store.list_refs("policies")?; + if json { + let entries: Vec = refs + .iter() + .filter_map(|(_, obj_id)| { + if let Ok(Object::Policy(policy)) = store.load_object(obj_id) { + Some(serde_json::json!({ + "policy_id": policy.policy_id, + "visibility": format!("{:?}", policy.visibility), + "required_checks": policy.required_checks, + "required_reviewers": policy.required_reviewers, + "sensitive_paths": policy.sensitive_paths, + "quarantine_lane": policy.quarantine_lane, + })) + } else { + None + } + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + if refs.is_empty() { println!("No policies found."); return Ok(()); diff --git a/crates/claw/src/commands/reflog.rs b/crates/claw/src/commands/reflog.rs new file mode 100644 index 0000000..20c4840 --- /dev/null +++ b/crates/claw/src/commands/reflog.rs @@ -0,0 +1,77 @@ +use clap::Args; + +use claw_store::reflog::{read_reflog, RefLogLine}; +use claw_store::{ClawStore, HeadState}; + +use crate::config::find_repo_root; + +#[derive(Args)] +pub struct ReflogArgs { + /// Ref to show reflog for (default: current branch) + #[arg(long, name = "ref")] + ref_name: Option, + /// Maximum number of entries + #[arg(long, default_value = "20")] + limit: usize, + /// Output as JSON + #[arg(long)] + json: bool, +} + +pub fn run(args: ReflogArgs) -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ref_name = match args.ref_name { + Some(r) => r, + None => { + let head = store.read_head()?; + match head { + HeadState::Symbolic { ref_name } => ref_name, + HeadState::Detached { .. } => { + anyhow::bail!( + "HEAD is detached; specify --ref to view a specific reflog" + ); + } + } + } + }; + + let entries = read_reflog(store.layout(), &ref_name)?; + + if entries.is_empty() { + println!("No reflog entries for '{}'.", ref_name); + return Ok(()); + } + + let display: Vec<&RefLogLine> = entries.iter().rev().take(args.limit).collect(); + + if args.json { + let json_entries: Vec = display + .iter() + .enumerate() + .map(|(i, entry)| { + serde_json::json!({ + "index": i, + "old": entry.old.to_hex(), + "new": entry.new.to_hex(), + "timestamp_ms": entry.timestamp_ms, + "author": entry.author, + "message": entry.message, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_entries)?); + } else { + let branch = ref_name.strip_prefix("heads/").unwrap_or(&ref_name); + for (i, entry) in display.iter().enumerate() { + let short_new = &entry.new.to_hex()[..12]; + println!( + "{}@{{{}}}: {} {} {}", + branch, i, short_new, entry.author, entry.message + ); + } + } + + Ok(()) +} diff --git a/crates/claw/src/commands/stash.rs b/crates/claw/src/commands/stash.rs new file mode 100644 index 0000000..11b128d --- /dev/null +++ b/crates/claw/src/commands/stash.rs @@ -0,0 +1,201 @@ +use clap::{Args, Subcommand}; + +use claw_core::object::Object; +use claw_core::types::Revision; +use claw_store::tree_diff::diff_trees; +use claw_store::{ClawStore, HeadState}; + +use crate::config::find_repo_root; +use crate::ignore::IgnoreRules; +use crate::worktree; + +#[derive(Args)] +pub struct StashArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum StashCommand { + /// Save working tree changes to the stash + Save { + /// Stash message + #[arg(short, long, default_value = "WIP")] + message: String, + }, + /// Restore the most recent stash entry + Pop, + /// List stash entries + List, + /// Drop a stash entry + Drop { + /// Stash index (default: 0) + #[arg(default_value = "0")] + index: usize, + }, +} + +pub fn run(args: StashArgs) -> anyhow::Result<()> { + let command = args.command.unwrap_or(StashCommand::Save { + message: "WIP".to_string(), + }); + + match command { + StashCommand::Save { message } => stash_save(message), + StashCommand::Pop => stash_pop(), + StashCommand::List => stash_list(), + StashCommand::Drop { index } => stash_drop(index), + } +} + +fn stash_save(message: String) -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + let ignore = IgnoreRules::load(&root); + + let head_state = store.read_head()?; + let _branch_ref = match &head_state { + HeadState::Symbolic { ref_name } => ref_name.clone(), + HeadState::Detached { .. } => anyhow::bail!("cannot stash in detached HEAD state"), + }; + + let head_id = store + .resolve_head()? + .ok_or_else(|| anyhow::anyhow!("no commits yet; nothing to stash"))?; + + // Scan worktree + let worktree_tree = worktree::scan_worktree(&store, &root, &ignore)?; + + // Check for actual changes + let head_obj = store.load_object(&head_id)?; + let head_tree = match head_obj { + Object::Revision(ref rev) => rev.tree, + _ => None, + }; + + let changes = diff_trees(&store, head_tree.as_ref(), Some(&worktree_tree), "")?; + if changes.is_empty() { + println!("No changes to stash."); + return Ok(()); + } + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + // Create a stash revision pointing at the worktree tree + let stash_rev = Revision { + change_id: None, + parents: vec![head_id], + patches: vec![], + snapshot_base: None, + tree: Some(worktree_tree), + capsule_id: None, + author: "stash".to_string(), + created_at_ms: now_ms, + summary: format!("stash: {}", message), + policy_evidence: vec![], + }; + + let stash_id = store.store_object(&Object::Revision(stash_rev))?; + + // Push onto the stash stack + let stash_refs = store.list_refs("stash/")?; + let next_index = stash_refs.len(); + let stash_ref = format!("stash/{}", next_index); + store.set_ref(&stash_ref, &stash_id)?; + + // Restore working tree to HEAD state + if let Some(ref tree_id) = head_tree { + worktree::materialize_tree(&store, tree_id, &root)?; + } + + println!( + "Saved working tree ({} file(s) changed): {}", + changes.len(), + message + ); + + Ok(()) +} + +fn stash_pop() -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let stash_refs = store.list_refs("stash/")?; + if stash_refs.is_empty() { + anyhow::bail!("no stash entries"); + } + + // Find highest index + let (max_ref, max_id) = stash_refs + .iter() + .max_by_key(|(name, _)| { + name.strip_prefix("stash/") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }) + .unwrap(); + + let stash_obj = store.load_object(max_id)?; + let stash_tree = match stash_obj { + Object::Revision(ref rev) => rev + .tree + .ok_or_else(|| anyhow::anyhow!("stash revision has no tree"))?, + _ => anyhow::bail!("stash entry is not a revision"), + }; + + // Restore stashed tree to working directory + worktree::materialize_tree(&store, &stash_tree, &root)?; + + // Remove the stash ref + store.delete_ref(max_ref)?; + + println!("Restored stash and dropped {}", max_ref); + Ok(()) +} + +fn stash_list() -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let mut stash_refs = store.list_refs("stash/")?; + if stash_refs.is_empty() { + println!("No stash entries."); + return Ok(()); + } + + // Sort by index descending (newest first) + stash_refs.sort_by_key(|(name, _)| { + name.strip_prefix("stash/") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }); + stash_refs.reverse(); + + for (name, id) in &stash_refs { + let index = name.strip_prefix("stash/").unwrap_or(name); + let summary = match store.load_object(id) { + Ok(Object::Revision(rev)) => rev.summary, + _ => "(unknown)".to_string(), + }; + println!("stash@{{{}}}: {}", index, summary); + } + + Ok(()) +} + +fn stash_drop(index: usize) -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ref_name = format!("stash/{}", index); + if store.get_ref(&ref_name)?.is_none() { + anyhow::bail!("stash@{{{}}} not found", index); + } + + store.delete_ref(&ref_name)?; + println!("Dropped stash@{{{}}}", index); + Ok(()) +} diff --git a/crates/claw/src/commands/sync.rs b/crates/claw/src/commands/sync.rs index 52f5c8f..38d0aec 100644 --- a/crates/claw/src/commands/sync.rs +++ b/crates/claw/src/commands/sync.rs @@ -292,6 +292,39 @@ pub async fn run(args: SyncArgs) -> anyhow::Result<()> { Ok(()) } +/// Top-level clone arguments (for `claw clone `) +#[derive(Args)] +pub struct CloneArgs { + /// Remote address + pub remote: String, + /// Transport kind (grpc|clawlab) + #[arg(long, default_value = "grpc")] + pub kind: String, + /// Repository slug for clawlab remotes + #[arg(long)] + pub repo: Option, + /// Auth profile for clawlab remotes + #[arg(long)] + pub token_profile: Option, + /// Local path + #[arg(default_value = ".")] + pub path: String, +} + +pub async fn run_clone(args: CloneArgs) -> anyhow::Result<()> { + run(SyncArgs { + command: Some(SyncCommand::Clone { + remote: args.remote, + kind: args.kind, + repo: args.repo, + token_profile: args.token_profile, + path: args.path, + }), + remote: None, + }) + .await +} + #[cfg(test)] mod tests { use clap::Parser; diff --git a/crates/claw/src/commands/tag.rs b/crates/claw/src/commands/tag.rs new file mode 100644 index 0000000..50b1944 --- /dev/null +++ b/crates/claw/src/commands/tag.rs @@ -0,0 +1,95 @@ +use clap::{Args, Subcommand}; + +use claw_store::ClawStore; + +use crate::config::find_repo_root; + +#[derive(Args)] +pub struct TagArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum TagCommand { + /// Create a new tag + Create { + /// Tag name (e.g. v1.0.0) + name: String, + /// Object to tag (default: HEAD) + #[arg(long)] + target: Option, + /// Tag message + #[arg(short, long)] + message: Option, + }, + /// Delete a tag + Delete { + /// Tag name + name: String, + }, +} + +pub fn run(args: TagArgs) -> anyhow::Result<()> { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + match args.command { + None => { + // List tags + let refs = store.list_refs("tags/")?; + if refs.is_empty() { + println!("No tags found."); + } else { + for (name, id) in &refs { + let short_name = name.strip_prefix("tags/").unwrap_or(name); + let short_id = &id.to_hex()[..12]; + println!("{} ({})", short_name, short_id); + } + } + } + Some(TagCommand::Create { + name, + target, + message, + }) => { + let ref_name = format!("tags/{}", name); + if store.get_ref(&ref_name)?.is_some() { + anyhow::bail!("tag '{}' already exists", name); + } + + let target_id = match target { + Some(t) => { + // Try as ref, then hex, then display + if let Some(id) = store.get_ref(&t)? { + id + } else if let Ok(id) = claw_core::id::ObjectId::from_hex(&t) { + if !store.has_object(&id) { + anyhow::bail!("object not found: {}", t); + } + id + } else { + anyhow::bail!("cannot resolve: {}", t); + } + } + None => store + .resolve_head()? + .ok_or_else(|| anyhow::anyhow!("no commits yet"))?, + }; + + let msg = message.as_deref().unwrap_or("tag"); + store.update_ref_cas(&ref_name, None, &target_id, "tag", msg)?; + println!("Created tag '{}' at {}", name, target_id); + } + Some(TagCommand::Delete { name }) => { + let ref_name = format!("tags/{}", name); + if store.get_ref(&ref_name)?.is_none() { + anyhow::bail!("tag '{}' not found", name); + } + store.delete_ref(&ref_name)?; + println!("Deleted tag '{}'", name); + } + } + + Ok(()) +} diff --git a/crates/claw/src/commands/workstream.rs b/crates/claw/src/commands/workstream.rs new file mode 100644 index 0000000..15583bc --- /dev/null +++ b/crates/claw/src/commands/workstream.rs @@ -0,0 +1,146 @@ +use clap::{Args, Subcommand}; + +use claw_core::id::ChangeId; +use claw_core::object::Object; +use claw_core::types::Workstream; +use claw_store::ClawStore; + +use crate::config::find_repo_root; + +#[derive(Args)] +pub struct WorkstreamArgs { + #[command(subcommand)] + command: WorkstreamCommand, +} + +#[derive(Subcommand)] +enum WorkstreamCommand { + /// Create a new workstream + Create { + /// Workstream ID + #[arg(long)] + id: String, + }, + /// Show a workstream + Show { + /// Workstream ID + id: String, + }, + /// List workstreams + List, + /// Add a change to a workstream + Push { + /// Workstream ID + #[arg(long)] + id: String, + /// Change ID to add + #[arg(long)] + change: String, + }, + /// Remove the top change from a workstream + Pop { + /// Workstream ID + #[arg(long)] + id: String, + }, +} + +pub fn run(args: WorkstreamArgs) -> anyhow::Result<()> { + match args.command { + WorkstreamCommand::Create { id } => { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ws = Workstream { + workstream_id: id.clone(), + change_stack: vec![], + }; + + let obj_id = store.store_object(&Object::Workstream(ws))?; + store.set_ref(&format!("workstreams/{}", id), &obj_id)?; + + println!("Created workstream: {}", id); + println!(" Object: {}", obj_id); + } + WorkstreamCommand::Show { id } => { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ref_name = format!("workstreams/{}", id); + let obj_id = store + .get_ref(&ref_name)? + .ok_or_else(|| anyhow::anyhow!("workstream not found: {}", id))?; + let obj = store.load_object(&obj_id)?; + + if let Object::Workstream(ws) = obj { + println!("Workstream: {}", ws.workstream_id); + println!(" Changes: {}", ws.change_stack.len()); + for (i, cid) in ws.change_stack.iter().enumerate() { + println!(" [{}] {}", i, cid); + } + } + } + WorkstreamCommand::List => { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let refs = store.list_refs("workstreams/")?; + if refs.is_empty() { + println!("No workstreams found."); + } else { + for (name, id) in &refs { + let short_name = name.strip_prefix("workstreams/").unwrap_or(name); + if let Ok(Object::Workstream(ws)) = store.load_object(id) { + println!("{} ({} changes)", short_name, ws.change_stack.len()); + } else { + println!("{}", short_name); + } + } + } + } + WorkstreamCommand::Push { id, change } => { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ref_name = format!("workstreams/{}", id); + let obj_id = store + .get_ref(&ref_name)? + .ok_or_else(|| anyhow::anyhow!("workstream not found: {}", id))?; + let obj = store.load_object(&obj_id)?; + + if let Object::Workstream(mut ws) = obj { + let change_id = ChangeId::from_string(&change)?; + ws.change_stack.push(change_id); + let new_id = store.store_object(&Object::Workstream(ws))?; + store.set_ref(&ref_name, &new_id)?; + println!("Pushed change {} onto workstream {}", change, id); + } else { + anyhow::bail!("ref does not point to a workstream: {}", ref_name); + } + } + WorkstreamCommand::Pop { id } => { + let root = find_repo_root()?; + let store = ClawStore::open(&root)?; + + let ref_name = format!("workstreams/{}", id); + let obj_id = store + .get_ref(&ref_name)? + .ok_or_else(|| anyhow::anyhow!("workstream not found: {}", id))?; + let obj = store.load_object(&obj_id)?; + + if let Object::Workstream(mut ws) = obj { + if let Some(popped) = ws.change_stack.pop() { + let new_id = store.store_object(&Object::Workstream(ws))?; + store.set_ref(&ref_name, &new_id)?; + println!("Popped change {} from workstream {}", popped, id); + } else { + println!("Workstream {} has no changes to pop.", id); + } + } else { + anyhow::bail!("ref does not point to a workstream: {}", ref_name); + } + } + } + + Ok(()) +} diff --git a/crates/claw/src/diff_render.rs b/crates/claw/src/diff_render.rs index 7f87357..8bb9a3e 100644 --- a/crates/claw/src/diff_render.rs +++ b/crates/claw/src/diff_render.rs @@ -1,30 +1,98 @@ -use similar::TextDiff; +use similar::{ChangeTag, TextDiff}; + +/// Check if stdout is a terminal (for color support). +fn use_color() -> bool { + std::io::IsTerminal::is_terminal(&std::io::stdout()) +} + +const RED: &str = "\x1b[31m"; +const GREEN: &str = "\x1b[32m"; +const CYAN: &str = "\x1b[36m"; +const BOLD: &str = "\x1b[1m"; +const RESET: &str = "\x1b[0m"; pub fn render_unified_diff(path: &str, old_bytes: &[u8], new_bytes: &[u8]) -> String { let old_str = String::from_utf8_lossy(old_bytes); let new_str = String::from_utf8_lossy(new_bytes); + let color = use_color(); let diff = TextDiff::from_lines(old_str.as_ref(), new_str.as_ref()); - let mut output = format!("--- a/{}\n+++ b/{}\n", path, path); - output.push_str( - &diff - .unified_diff() - .context_radius(3) - .header(&format!("a/{}", path), &format!("b/{}", path)) - .to_string(), + + if !color { + let mut output = format!("--- a/{}\n+++ b/{}\n", path, path); + output.push_str( + &diff + .unified_diff() + .context_radius(3) + .header(&format!("a/{}", path), &format!("b/{}", path)) + .to_string(), + ); + return output; + } + + let mut output = format!( + "{BOLD}--- a/{path}{RESET}\n{BOLD}+++ b/{path}{RESET}\n" ); + + for hunk in diff.unified_diff().context_radius(3).iter_hunks() { + output.push_str(&format!("{CYAN}{}{RESET}\n", hunk.header())); + for change in hunk.iter_changes() { + match change.tag() { + ChangeTag::Delete => { + output.push_str(&format!("{RED}-{}{RESET}", change.value())); + if change.missing_newline() { + output.push('\n'); + } + } + ChangeTag::Insert => { + output.push_str(&format!("{GREEN}+{}{RESET}", change.value())); + if change.missing_newline() { + output.push('\n'); + } + } + ChangeTag::Equal => { + output.push(' '); + output.push_str(change.value()); + if change.missing_newline() { + output.push('\n'); + } + } + } + } + } + output } pub fn render_json_diff(path: &str, ops: &[claw_core::types::PatchOp]) -> String { - let mut output = format!("--- a/{}\n+++ b/{}\n", path, path); + let color = use_color(); + let mut output = if color { + format!("{BOLD}--- a/{path}{RESET}\n{BOLD}+++ b/{path}{RESET}\n") + } else { + format!("--- a/{}\n+++ b/{}\n", path, path) + }; + for op in ops { output.push_str(&format!(" {} @{}: ", op.op_type, op.address)); if let Some(old) = &op.old_data { - output.push_str(&format!("old={:?} ", String::from_utf8_lossy(old))); + if color { + output.push_str(&format!( + "{RED}old={:?}{RESET} ", + String::from_utf8_lossy(old) + )); + } else { + output.push_str(&format!("old={:?} ", String::from_utf8_lossy(old))); + } } if let Some(new) = &op.new_data { - output.push_str(&format!("new={:?}", String::from_utf8_lossy(new))); + if color { + output.push_str(&format!( + "{GREEN}new={:?}{RESET}", + String::from_utf8_lossy(new) + )); + } else { + output.push_str(&format!("new={:?}", String::from_utf8_lossy(new))); + } } output.push('\n'); } diff --git a/crates/claw/src/main.rs b/crates/claw/src/main.rs index 51ddd8b..71b4b4d 100644 --- a/crates/claw/src/main.rs +++ b/crates/claw/src/main.rs @@ -2,7 +2,7 @@ use clap::Parser; use tracing_subscriber::EnvFilter; mod auth_store; -mod commands; +pub mod commands; mod config; mod conflict_writer; mod diff_render; @@ -20,7 +20,7 @@ use commands::Commands; version, about = "Intent-native, agent-native version control" )] -struct Cli { +pub struct Cli { #[command(subcommand)] command: Commands, }