Identify backlogs by name instead of key
Backlogs no longer expose their jj-style key. The key column remains in
the schema for now (with a dummy value on insert), but all code references
use name. Added unique index on backlog name and RANGER_DEFAULT_BACKLOG
env var support on all --backlog flags.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/AGENTS.md b/AGENTS.md
index f4da21a..9b35acb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,8 +32,8 @@ ranger backlog create "Ranger"
Before starting work, check the backlog:
```bash
-ranger task list --backlog <key> --state queued
-ranger task list --backlog <key> --state in_progress
+ranger task list --backlog <name> --state queued
+ranger task list --backlog <name> --state in_progress
```
When picking up a task:
@@ -60,9 +60,9 @@ ranger comment add <key> "Completed — summary of what was done"
To add new work:
```bash
-ranger task create "Title" --backlog <key> # icebox by default
-ranger task create "Title" --backlog <key> --state queued # committed work
-ranger task create "Subtask" --backlog <key> --parent <key> # subtask
+ranger task create "Title" --backlog <name> # icebox by default
+ranger task create "Title" --backlog <name> --state queued # committed work
+ranger task create "Subtask" --backlog <name> --parent <key> # subtask
```
### Prioritization
@@ -70,12 +70,12 @@ ranger task create "Subtask" --backlog <key> --parent <key> # subtask
When adding queued tasks, consider where they belong relative to existing work. New tasks land at the bottom by default — reposition them if they're higher priority:
```bash
-ranger task list --backlog <key> --state queued # see current order
-ranger task move <key> --backlog <key> --before <key> # place before a task
-ranger task move <key> --backlog <key> --after <key> # place after a task
+ranger task list --backlog <name> --state queued # see current order
+ranger task move <key> --backlog <name> --before <key> # place before a task
+ranger task move <key> --backlog <name> --after <key> # place after a task
```
-Top of the queue = most important. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
+Top of the queue = most important. Only move tasks within the queued state — don't reposition done, in_progress, or icebox tasks. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
**Bias toward quick wins**: Small, easy tasks should be prioritized higher by default — even if they're just nice-to-haves. A 5-minute fix that improves quality of life is worth doing before a multi-hour feature. When suggesting priority, bump quick wins up rather than defaulting them to the bottom.
diff --git a/migrations/002_unique_backlog_name.sql b/migrations/002_unique_backlog_name.sql
new file mode 100644
index 0000000..7a05c24
--- /dev/null
+++ b/migrations/002_unique_backlog_name.sql
@@ -0,0 +1 @@
+CREATE UNIQUE INDEX IF NOT EXISTS idx_backlogs_name ON backlogs(name);
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 67bd5c4..3d05216 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -17,8 +17,8 @@ pub enum BacklogCommands {
List,
/// Show a backlog's details
Show {
- /// Key or key prefix of the backlog
- key: String,
+ /// Backlog name
+ name: String,
},
}
@@ -32,8 +32,8 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
let backlogs = ops::backlog::list(pool).await?;
output::print_list(&backlogs, json, print_backlog);
}
- BacklogCommands::Show { key } => {
- let backlog = ops::backlog::get_by_key_prefix(pool, &key).await?;
+ BacklogCommands::Show { name } => {
+ let backlog = ops::backlog::get_by_name(pool, &name).await?;
if json {
let mut state_groups = serde_json::Map::new();
@@ -68,11 +68,10 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
}
fn print_backlog(b: &Backlog) {
- println!("{} {}", &b.key[..8], b.name);
+ println!("{}", b.name);
}
fn print_backlog_detail(b: &Backlog) {
- println!("Key: {}", b.key);
println!("Name: {}", b.name);
println!("Created: {}", b.created_at);
println!("Updated: {}", b.updated_at);
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 76bc30b..b9fd053 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -129,7 +129,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
tag,
position,
} => {
- let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+ let bl = ops::backlog::get_by_name(pool, &backlog).await?;
let parent_id = if let Some(parent_key) = &parent {
Some(ops::task::get_by_key_prefix(pool, parent_key).await?.id)
} else {
@@ -165,7 +165,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let state = state.map(|s| s.parse::<State>()).transpose()?;
if let Some(backlog_key) = &backlog {
- let bl = ops::backlog::get_by_key_prefix(pool, backlog_key).await?;
+ let bl = ops::backlog::get_by_name(pool, backlog_key).await?;
let tasks = ops::task::list(pool, bl.id, state).await?;
output::print_list(&tasks, json, print_task);
} else {
@@ -244,7 +244,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
backlog,
position,
} => {
- let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+ let bl = ops::backlog::get_by_name(pool, &backlog).await?;
let task = ops::task::get_by_key_prefix(pool, &key).await?;
let (before_id, after_id) = position.resolve(pool).await?;
@@ -253,13 +253,13 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
}
TaskCommands::Add { task, backlog } => {
let t = ops::task::get_by_key_prefix(pool, &task).await?;
- let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+ let bl = ops::backlog::get_by_name(pool, &backlog).await?;
ops::task::add_to_backlog(pool, t.id, bl.id).await?;
println!("Added {} to {}", &t.key[..8], bl.name);
}
TaskCommands::Remove { task, backlog } => {
let t = ops::task::get_by_key_prefix(pool, &task).await?;
- let bl = ops::backlog::get_by_key_prefix(pool, &backlog).await?;
+ let bl = ops::backlog::get_by_name(pool, &backlog).await?;
ops::task::remove_from_backlog(pool, t.id, bl.id).await?;
println!("Removed {} from {}", &t.key[..8], bl.name);
}
diff --git a/src/db.rs b/src/db.rs
index 6ead83d..0eaa40c 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -27,6 +27,9 @@ async fn migrate(pool: &SqlitePool) -> Result<(), RangerError> {
sqlx::raw_sql(include_str!("../migrations/001_initial.sql"))
.execute(pool)
.await?;
+ sqlx::raw_sql(include_str!("../migrations/002_unique_backlog_name.sql"))
+ .execute(pool)
+ .await?;
Ok(())
}
diff --git a/src/models.rs b/src/models.rs
index e81a22f..56b23be 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -72,7 +72,6 @@ impl sqlx::Encode<'_, sqlx::Sqlite> for State {
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Backlog {
pub id: i64,
- pub key: String,
pub name: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
diff --git a/src/ops/backlog.rs b/src/ops/backlog.rs
index 860d1cf..4f48ce7 100644
--- a/src/ops/backlog.rs
+++ b/src/ops/backlog.rs
@@ -4,9 +4,10 @@ use crate::models::Backlog;
use sqlx::SqlitePool;
pub async fn create(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerError> {
+ // key column still exists in the schema but is unused; generate a dummy value
let key = key::generate_key();
let backlog = sqlx::query_as::<_, Backlog>(
- "INSERT INTO backlogs (key, name) VALUES (?, ?) RETURNING id, key, name, created_at, updated_at",
+ "INSERT INTO backlogs (key, name) VALUES (?, ?) RETURNING id, name, created_at, updated_at",
)
.bind(&key)
.bind(name)
@@ -17,27 +18,22 @@ pub async fn create(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerErro
pub async fn list(pool: &SqlitePool) -> Result<Vec<Backlog>, RangerError> {
let backlogs = sqlx::query_as::<_, Backlog>(
- "SELECT id, key, name, created_at, updated_at FROM backlogs ORDER BY name",
+ "SELECT id, name, created_at, updated_at FROM backlogs ORDER BY name",
)
.fetch_all(pool)
.await?;
Ok(backlogs)
}
-pub async fn get_by_key_prefix(pool: &SqlitePool, prefix: &str) -> Result<Backlog, RangerError> {
- let pattern = format!("{prefix}%");
- let matches = sqlx::query_as::<_, Backlog>(
- "SELECT id, key, name, created_at, updated_at FROM backlogs WHERE key LIKE ?",
+pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Backlog, RangerError> {
+ let backlog = sqlx::query_as::<_, Backlog>(
+ "SELECT id, name, created_at, updated_at FROM backlogs WHERE name = ?",
)
- .bind(&pattern)
- .fetch_all(pool)
- .await?;
-
- match matches.len() {
- 0 => Err(RangerError::KeyNotFound(prefix.to_string())),
- 1 => Ok(matches.into_iter().next().unwrap()),
- _ => Err(RangerError::AmbiguousPrefix(prefix.to_string())),
- }
+ .bind(name)
+ .fetch_optional(pool)
+ .await?
+ .ok_or_else(|| RangerError::KeyNotFound(name.to_string()))?;
+ Ok(backlog)
}
#[cfg(test)]
@@ -57,9 +53,8 @@ mod tests {
let pool = test_pool().await;
let backlog = create(&pool, "My Backlog").await.unwrap();
assert_eq!(backlog.name, "My Backlog");
- assert!(!backlog.key.is_empty());
- let fetched = get_by_key_prefix(&pool, &backlog.key[..3]).await.unwrap();
+ let fetched = get_by_name(&pool, "My Backlog").await.unwrap();
assert_eq!(fetched.id, backlog.id);
}
@@ -74,9 +69,9 @@ mod tests {
}
#[tokio::test]
- async fn get_by_key_prefix_not_found() {
+ async fn get_by_name_not_found() {
let pool = test_pool().await;
- let result = get_by_key_prefix(&pool, "nonexistent").await;
+ let result = get_by_name(&pool, "nonexistent").await;
assert!(result.is_err());
}
}
diff --git a/tests/cli.rs b/tests/cli.rs
index 0eb181f..500d385 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -5,6 +5,7 @@ use tempfile::tempdir;
fn ranger(db_path: &str) -> Command {
let mut cmd = Command::from(cargo_bin_cmd!("ranger"));
cmd.env("RANGER_DB", db_path);
+ cmd.env("RANGER_DEFAULT_BACKLOG", "Ranger");
cmd
}
@@ -23,48 +24,31 @@ fn full_workflow() {
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Ranger"));
- // List backlogs (JSON) and extract key
+ // List backlogs (JSON)
let output = ranger(db_path)
.args(["backlog", "list", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let backlogs: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
- let backlog_key = backlogs[0]["key"].as_str().unwrap().to_string();
- let bl_prefix = &backlog_key[..4];
+ assert_eq!(backlogs[0]["name"], "Ranger");
- // Create tasks
+ // Create tasks (using RANGER_DEFAULT_BACKLOG)
let output = ranger(db_path)
- .args([
- "task",
- "create",
- "First task",
- "--backlog",
- bl_prefix,
- "--state",
- "queued",
- ])
+ .args(["task", "create", "First task", "--state", "queued"])
.output()
.unwrap();
assert!(output.status.success());
let output = ranger(db_path)
- .args([
- "task",
- "create",
- "Second task",
- "--backlog",
- bl_prefix,
- "--tag",
- "urgent",
- ])
+ .args(["task", "create", "Second task", "--tag", "urgent"])
.output()
.unwrap();
assert!(output.status.success());
// List tasks (JSON) and verify ordering
let output = ranger(db_path)
- .args(["task", "list", "--backlog", bl_prefix, "--json"])
+ .args(["task", "list", "--json"])
.output()
.unwrap();
assert!(output.status.success());
@@ -135,7 +119,7 @@ fn full_workflow() {
// Verify deletion
let output = ranger(db_path)
- .args(["task", "list", "--backlog", bl_prefix, "--json"])
+ .args(["task", "list", "--json"])
.output()
.unwrap();
assert!(output.status.success());