Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .specs/v1.0.0-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# v1.0.0 — Stability

## Goal

Harden the codebase for a stable 1.0.0 release: full test coverage, stable formats, clean documentation, and a repository audit.

---

## 1. Repository Audit

**What:** Review the entire repo for stale files, inconsistencies, and housekeeping.

**Spec:**
- Remove unused dev-dependencies from `Cargo.toml`
- Verify all `#[allow(dead_code)]` and `#[allow(clippy::...)]` are still needed; remove stale ones
- Check for TODO/FIXME/HACK comments — resolve or remove
- Verify `.gitignore` is clean (no stale entries)
- Verify `AGENTS.md` reflects current state (module table, test structure, conventions)
- Remove any dead code paths or unreachable branches
- Ensure all public API items are used by integration tests

**Checkpoint:** `cargo clippy -- -D warnings` and `cargo test` pass; no stale annotations or dead code.

---

## 2. Stable Registry Format

**What:** Document and freeze the registry TOML schema so future changes include migration paths.

**Spec:**
- Document `InstalledPackage` schema in a `SCHEMA.md` file at repo root
- List all fields, their types, and whether they're required or defaulted
- State: any future field additions must use `#[serde(default)]` for backward compatibility
- Any future field removals must include a migration step in the CHANGELOG
- Add a `format_version` field to `Registry` (default `"1"`) for future migration detection

**Checkpoint:** `SCHEMA.md` exists and matches the actual `InstalledPackage` struct; `format_version` roundtrips through TOML.

---

## 3. Stable Config Format

**What:** Same treatment for the config schema.

**Spec:**
- Document config schema in `SCHEMA.md` alongside registry
- List all fields, types, defaults, and config sources (env, local, XDG, legacy)
- State: any future config additions must have defaults; no breaking removals without a major version bump

**Checkpoint:** Config schema documented; matches actual `Config` struct.

---

## 4. Full Test Coverage

**What:** Identify untested public API and add integration tests.

**Spec:**
- List all `pub fn` in the crate; identify gaps vs `tests/`
- Minimum coverage targets:
- All CLI commands have at least one integration test
- All `pub fn` in `core/` and `network/` modules are exercised
- Error paths for invalid input are tested
- Add `tests/cli.rs` for command-line parsing edge cases
- Add `tests/integration.rs` for end-to-end workflows (install -> list -> update -> uninstall)
- Note: network-dependent tests (actual GitHub API calls) are out of scope for CI; focus on unit/integration tests that mock or avoid network

**Checkpoint:** Every `pub fn` has at least one test; `cargo test` passes with 0 failures.

---

## 5. Documentation Cleanup

**What:** Ensure README, CHANGELOG, and AGENTS.md are accurate and complete.

**Spec:**
- README.md: verify all examples work, all commands listed, supported platforms accurate
- CHANGELOG.md: verify all entries match actual releases
- AGENTS.md: update module table to include all current modules (channel, export, cache, etc.)
- ROADMAP.md: clear — all planned features shipped

**Checkpoint:** No outdated info in any documentation file.

---

## Implementation Order

1. Repository audit (find issues first)
2. Documentation cleanup (fix what audit found)
3. Stable formats (SCHEMA.md, format_version field)
4. Full test coverage (fill gaps)
5. Final pass: fmt, clippy, test, version bump to 1.0.0

## Test Plan

- All existing tests pass
- New tests cover previously untested public API
- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass
- SCHEMA.md validated against actual structs
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name = "gitclaw"
version = "0.7.0"
edition = "2021"

[lib]
name = "gitclaw"
path = "src/lib.rs"

[[bin]]
name = "gitclaw"
path = "src/main.rs"
Expand Down
102 changes: 2 additions & 100 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,103 +1,5 @@
# ROADMAP

Planned features and improvements toward gitclaw 1.0.0.
*All planned features have been shipped. gitclaw is approaching 1.0.0 stability.*

## 0.4.0 — Dependency Management ✅

**Semver range support**

Install versions matching constraints:

```bash
gitclaw install user/repo ">=1.0.0"
gitclaw install user/repo "^1.2.3"
```

**Lockfile support**

Reproducible installs via `gitclaw.lock`:

```bash
gitclaw lock
gitclaw install --locked
```

**Package aliases**

Short names for frequently used packages:

```bash
gitclaw alias rg BurntSushi/ripgrep
gitclaw install rg
```

## 0.5.0 — User Experience ✅ ✅

**Asset caching**

Cache downloaded archives to `~/.gitclaw/cache/` — skip re-download if hash matches.

```bash
gitclaw cache clean
gitclaw cache size
```

**Outdated check**

```bash
gitclaw list --outdated
```

Compares installed version against latest GitHub release.

**Local installs**

Project-scoped installation isolated from the global registry:

```bash
gitclaw install --local user/repo
```

## 0.6.0 — Advanced Features ✅ ✅

**Release channels**

```bash
gitclaw install user/repo --channel nightly
gitclaw install user/repo --channel beta
```

**Export / import**

Share package lists between machines:

```bash
gitclaw export > deps.toml
gitclaw import deps.toml
```

## 0.7.0 — Platform Integration ✅

**Package manager awareness**

Warn when a package is already available via a system package manager (apt, etc.).

## 1.0.0 — Stability

- All 0.x features stable and documented
- Stable registry format (no breaking changes without migration)
- Stable config format
- Full test coverage across all modules
- Published to crates.io

## Rejected Ideas

| Idea | Reason |
|------|--------|
| Package signing | Checksums are sufficient for the use case |
| Auto-update on launch | Too noisy; explicit is better |
| GUI application | Out of scope; TUI is sufficient |
| Telemetry | Privacy concerns |
| Web dashboard | Out of scope for a CLI tool |

*Last updated: 2026-04-23*
*Last updated: 2026-04-23*
4 changes: 2 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use clap::{Parser, Subcommand};
use clap_complete::Shell;

use crate::core::constants::{APP_NAME, ENV_VAR_TOKEN};
use crate::constants::{APP_NAME, ENV_VAR_TOKEN};

#[derive(Parser)]
#[command(
name = APP_NAME,
about = "Install software from GitHub releases.",
version,
before_help = crate::output::BANNER
before_help = crate::banner::BANNER
)]
pub struct Cli {
#[command(subcommand)]
Expand Down
43 changes: 19 additions & 24 deletions src/core/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@ use serde::{Deserialize, Serialize};

use crate::core::config::Config;

const ALIASES_FILE: &str = "aliases.toml";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AliasMap {
#[serde(flatten)]
pub aliases: HashMap<String, String>,
}

const ALIASES_FILE: &str = "aliases.toml";

impl AliasMap {
pub fn load(config: &Config) -> Result<Self> {
let path = config.install_dir.join(ALIASES_FILE);

if !path.exists() {
return Ok(Self::default());
}

let content = fs::read_to_string(&path).with_context(|| "Failed to read aliases file")?;
toml::from_str(&content).with_context(|| "Failed to parse aliases file")
}
Expand Down Expand Up @@ -60,13 +62,15 @@ impl AliasMap {

pub fn check_clash(&self, name: &str, config: &Config) -> Option<String> {
let registry_path = crate::core::util::registry_path_from(&config.install_dir);

if let Ok(reg) = crate::core::registry::Registry::load_from(&registry_path) {
for pkg in reg.packages.values() {
if pkg.repo == name || pkg.identifier == name {
return Some(format!("{}/{}", pkg.owner, pkg.repo));
}
}
}

None
}

Expand Down Expand Up @@ -99,9 +103,11 @@ pub fn handle_alias_add(alias: &str, target: &str, config: &Config) -> Result<()

pub fn handle_alias_remove(alias: &str, config: &Config) -> Result<()> {
let mut aliases = AliasMap::load(config)?;

if !aliases.remove(alias) {
bail!("Alias '{}' not found.", alias);
}

aliases.save(config)?;
crate::output::print_success(&format!("Alias '{}' removed.", alias));
Ok(())
Expand Down Expand Up @@ -132,25 +138,26 @@ pub fn handle_alias_list(config: &Config) -> Result<()> {
mod tests {
use super::*;

#[test]
fn test_alias_add() {
fn make_config() -> (Config, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let config = Config {
install_dir: dir.path().to_path_buf(),
..Config::default()
};
(config, dir)
}

#[test]
fn test_alias_add() {
let (config, _dir) = make_config();
let mut aliases = AliasMap::default();
aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap();
assert_eq!(aliases.resolve("rg"), Some("BurntSushi/ripgrep"));
}

#[test]
fn test_alias_add_slash_rejected() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
install_dir: dir.path().to_path_buf(),
..Config::default()
};
let (config, _dir) = make_config();
let mut aliases = AliasMap::default();
assert!(aliases
.add("owner/repo", "BurntSushi/ripgrep", &config)
Expand All @@ -159,11 +166,7 @@ mod tests {

#[test]
fn test_alias_remove() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
install_dir: dir.path().to_path_buf(),
..Config::default()
};
let (config, _dir) = make_config();
let mut aliases = AliasMap::default();
aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap();
assert!(aliases.remove("rg"));
Expand All @@ -179,11 +182,7 @@ mod tests {

#[test]
fn test_alias_list_sorted() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
install_dir: dir.path().to_path_buf(),
..Config::default()
};
let (config, _dir) = make_config();
let mut aliases = AliasMap::default();
aliases.add("fd", "sharkdp/fd", &config).unwrap();
aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap();
Expand All @@ -197,11 +196,7 @@ mod tests {

#[test]
fn test_alias_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
install_dir: dir.path().to_path_buf(),
..Config::default()
};
let (config, _dir) = make_config();

let mut aliases = AliasMap::default();
aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap();
Expand Down
8 changes: 4 additions & 4 deletions src/core/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ impl Channel {
return patterns.clone();
}
}

self.default_patterns()
}
}
Expand All @@ -77,7 +78,6 @@ pub fn matches_channel(tag: &str, patterns: &[String]) -> bool {
}
}

// If all patterns are exclusions, tag passes unless excluded
let all_exclusions = patterns.iter().all(|p| p.starts_with('!'));
if all_exclusions {
return true;
Expand All @@ -91,10 +91,10 @@ fn glob_match(text: &str, pattern: &str) -> bool {
return true;
}

let starts_with = pattern.starts_with('*');
let ends_with = pattern.ends_with('*');
let starts_with_wildcard = pattern.starts_with('*');
let ends_with_wildcard = pattern.ends_with('*');

match (starts_with, ends_with) {
match (starts_with_wildcard, ends_with_wildcard) {
(true, true) => {
let inner = &pattern[1..pattern.len() - 1];
text.contains(inner)
Expand Down
Loading
Loading