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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ first added in the same Unreleased section is not an entry: edit that entry inst
### Fixed

- The starter tour and the site footer link to https://x.com/smarzban. A tour already on your board keeps its notes.
- `tsk list -p` and `tsk project archive|unarchive` refuse a name two projects share, like `tsk add`, instead of matching nothing. Thanks @s3rgiosan (#249).
- `tsk list --all --group project` labels projects that share a name by their shortest distinct path, such as `site-a/wordpress/wp-content`. Thanks @s3rgiosan (#249).
- A bare token in an https backup URL is removed and masked even when the URL does not start at the scheme.
- An open board shows the update notice as soon as the release check finishes, and a board left open checks again once a day.
- A token in a backup URL stays out of git's command line and the backup's local clone, so other local users cannot see it.
Expand Down
7 changes: 4 additions & 3 deletions site/src/content/docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Rows the board paints as `N1`, `N2`… are notices: starter tasks and release no

- A bare name for `add` must match exactly one project the board knows: one with tasks, an archived one, or the repository you are in. Otherwise it refuses with `unknown-project`.
- Only an absolute path to an existing directory creates a new project. Relative and missing paths refuse.
- `tsk list -p <name>` never refuses: an unknown name lists nothing.
- `tsk list -p <name>` lists nothing for an unknown name. A name that two or more projects share refuses with `unknown-project` and lists their paths: pass one of them instead.

## add

Expand Down Expand Up @@ -182,7 +182,7 @@ tsk list --all --thread release --json
- Tasks group by status in the order `STARTED`, `READY`, `OPEN`, `BLOCKED`, `REVIEW`, and keep the board's order inside each status.
- A row reads `- 12 Fix login timeout @claude ⎇ main #auth`. A blocked row adds `blocked: <why>` beneath.
- With `--all`, each status groups its rows by project and desk. A sort other than `recent` lists the rows flat instead, each ending with its scope.
- With `--group`, sub-headings name each group as the board paints it.
- With `--group`, sub-headings name each group as the board paints it. Projects that share a name read as their shortest distinct path, such as `site-a/wordpress/wp-content`.
- One task in full adds its notes, steps, `@assignee`, `⎇ base`, `after` and `before` tasks, `#thread`, and its latest five [paper trail](/docs/task-page/#paper-trail) entries.
- Text wraps to the terminal width. Redirected output keeps each line whole.

Expand Down Expand Up @@ -593,7 +593,7 @@ tsk project archive <name> [--state-dir <dir>]
tsk project unarchive <name> [--state-dir <dir>]
```

Output is `archived project <name>` or `unarchived project <name>`. Repeating it is safe. A name with no tasks refuses: `no project named <name> has tasks`.
Output is `archived project <name>` or `unarchived project <name>`. Repeating it is safe. A name with no tasks refuses: `no project named <name> has tasks`. A name that two or more projects share refuses with `project <name> is ambiguous: <paths>`: pass one of the paths.

**Refusals:** a message with no code, exit 1 ([exit contract](#exit-contract)).

Expand Down Expand Up @@ -777,6 +777,7 @@ A refusal prints `tsk <command>: <code>: <message>` on stderr, such as `tsk stat
| Command | Refusal codes |
| --- | --- |
| `add` | `empty-title`, `invalid-title`, `unknown-project`, `project-archived`, `unknown-base`; in a plan also `invalid-item`, `invalid-thread`, `invalid-assignee`, `invalid-after` |
| `list` | `unknown-project` |
| `status` | `unknown-task`, `soft-deleted-task`, `text-too-long`, `invalid-blocker` |
| `status … started` | `after-not-done`, `agent-gone`, and the `dispatch` codes `other-host`, `needs-git-project`, `unknown-agent`, `agent-config`, `unknown-base`, `no-default-base`, `host-failed`, `unsafe-state-dir`; on Windows `unsupported-platform` |
| `status … done --clean` | The `clean` codes except `not-dispatched` and `already-cleaned` |
Expand Down
3 changes: 2 additions & 1 deletion skills/tsk-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ task's running agent, in Herdr or Rex, add `--send`: it sends only that reply an
`dispatch`, `edit --append-notes`, `steps add`, `steps toggle`, and `steps remove` are not:
read the task first.
4. **Use a known project name or a full path.** For `tsk add -p <project>`,
unknown or ambiguous names refuse; `tsk list -p` lists nothing for them. Only an
unknown or ambiguous names refuse; `tsk list -p` lists nothing for an unknown name and
refuses an ambiguous one. Only an
existing absolute directory creates a project ([scope](https://gettsk.sh/docs/cli.md#scope)).
5. **Never pass `--state-dir`** unless the user asked. It points at a different board.
6. **Ignore notice rows.** Rows the board paints as `N1`… are for the user; no command reaches them.
Expand Down
12 changes: 10 additions & 2 deletions src/cli/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,19 @@ pub fn run_project(
// Project verbs keep the old permissive path contract so outside-Git invocation
// directories, removed directories, and legacy relative scopes do not alter how an
// existing project is addressed.
let resolved = crate::scope::resolve_permissive_project_path(
let resolved = match crate::scope::resolve_permissive_project_path(
&name,
domain,
Some(&crate::context::snapshot_from_env()),
);
) {
Ok(resolved) => resolved,
Err(error) => {
return Ok((
Err(ArchiveCliError::UnknownProject(error.message(&name))),
false,
))
}
};
let result = if archive {
domain.archive_project(&resolved)
} else {
Expand Down
55 changes: 48 additions & 7 deletions src/cli/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub enum ListError {
Store(String),
/// A well-formed task address that addresses no task in the store.
UnknownTask,
/// A bare `-p` name that matches more than one project; the refusal message.
UnknownProject(String),
}

/// One task visible to the list command.
Expand Down Expand Up @@ -439,13 +441,15 @@ pub fn run(input: ListInput) -> Result<ListResult, ListError> {
}),
});
}
let scope = (!input.all).then(|| match input.project.as_deref() {
Some(project) => TaskScope::Project {
path: resolve_permissive_project_path(project, &domain, Some(&snapshot_from_env())),
},
None if input.global => TaskScope::Global,
None => snapshot_from_env().default_scope,
});
let scope = match (input.all, input.project.as_deref()) {
(true, _) => None,
(false, Some(project)) => Some(TaskScope::Project {
path: resolve_permissive_project_path(project, &domain, Some(&snapshot_from_env()))
.map_err(|error| ListError::UnknownProject(error.message(project)))?,
}),
(false, None) if input.global => Some(TaskScope::Global),
(false, None) => Some(snapshot_from_env().default_scope),
};
let view = if input.deleted {
ListView::Deleted
} else if input.done {
Expand Down Expand Up @@ -533,6 +537,9 @@ pub fn run(input: ListInput) -> Result<ListResult, ListError> {
arranged.extend(rows.into_iter().map(|task| (label.clone(), task)));
}
}
if group == GroupBy::Project {
distinguish_project_groups(&mut arranged);
}
arranged
};
// Group labels name you as `[board] me` for the text listing; JSON keeps the stored
Expand Down Expand Up @@ -673,6 +680,40 @@ fn is_open(status: HumanStatus) -> bool {
)
}

/// Relabel `--group project` groups whose basenames collide with the shortest unique path
/// suffix, so two `wp-content` projects read as two groups. Equivalent spellings of one
/// project (`/tmp/x`, `/private/tmp/x`) share one group and so one label.
fn distinguish_project_groups(arranged: &mut [(Option<String>, &crate::domain::Task)]) {
let identities = PathIdentityCache::default();
// One representative spelling per project identity, the first one listed.
let mut representatives: Vec<String> = Vec::new();
let mut representative_of = |path: &str| -> String {
match representatives
.iter()
.find(|known| identities.equivalent(known, path))
{
Some(known) => known.clone(),
None => {
representatives.push(path.to_string());
path.to_string()
}
}
};
let keys = arranged
.iter()
.map(|(_, task)| match &task.scope {
TaskScope::Project { path } => Some(representative_of(path)),
TaskScope::Global => None,
})
.collect::<Vec<_>>();
let labels = crate::cli::presenter::distinct_scope_labels(keys.iter().cloned());
for ((label, _), key) in arranged.iter_mut().zip(&keys) {
if key.is_some() {
*label = labels.get(key).cloned();
}
}
}

fn status_group_rank(status: HumanStatus) -> u8 {
match status {
HumanStatus::Started => 0,
Expand Down
43 changes: 31 additions & 12 deletions src/cli/presenter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,12 @@ pub fn list_help(_terminal_width: Option<usize>) -> CliOutput {
"tsk list --all --group project --sort title".into(),
"tsk list --archived --all".into(),
],
refusals: Vec::new(),
exit: exit_line("tasks listed", None, true),
refusals: vec!["unknown-project: a -p name shared by two or more projects".into()],
exit: exit_line(
"tasks listed",
Some("a -p name matches more than one project"),
true,
),
})
}

Expand Down Expand Up @@ -1273,26 +1277,30 @@ fn append_wrapped(
}

fn scope_labels(rows: &[ListRow]) -> BTreeMap<Option<String>, String> {
distinct_scope_labels(rows.iter().map(|row| row.project.clone()))
}

/// One distinct label per scope (`None` is the desk): a project's basename, widened to
/// the shortest unique path suffix where basenames collide (`site-a/wordpress/wp-content`).
pub(crate) fn distinct_scope_labels(
scopes: impl IntoIterator<Item = Option<String>>,
) -> BTreeMap<Option<String>, String> {
let mut entries = Vec::new();
let mut empty_projects = 0;
for row in rows {
for scope in scopes {
if entries
.iter()
.any(|entry: &ScopeLabel| entry.scope == row.project)
.any(|entry: &ScopeLabel| entry.scope == scope)
{
continue;
}
let empty_number = if row
.project
.as_deref()
.is_some_and(|path| path.trim().is_empty())
{
let empty_number = if scope.as_deref().is_some_and(|path| path.trim().is_empty()) {
empty_projects += 1;
empty_projects
} else {
0
};
entries.push(ScopeLabel::new(row.project.clone(), empty_number));
entries.push(ScopeLabel::new(scope, empty_number));
}

loop {
Expand Down Expand Up @@ -2475,10 +2483,13 @@ pub fn project_help() -> CliOutput {
"tsk project archive atlas".into(),
"tsk project unarchive atlas".into(),
],
refusals: vec!["no project named <name> has tasks".into()],
refusals: vec![
"no project named <name> has tasks".into(),
"project <name> is ambiguous: <paths>".into(),
],
exit: exit_line(
"project archived or unarchived, or already was",
Some("no project of that name has tasks"),
Some("no project of that name has tasks, or the name is ambiguous"),
true,
),
})
Expand Down Expand Up @@ -2559,6 +2570,14 @@ pub fn list_rejected(error: ListError, terminal_width: Option<usize>) -> CliOutp
},
// A well-formed address that addresses no task: the invocation is wrong, not the store.
ListError::UnknownTask => list_usage("unknown task", terminal_width),
ListError::UnknownProject(detail) => CliOutput {
stdout: String::new(),
stderr: wrap_list_document(
&format!("tsk list: unknown-project: {}\n", terminal_text(&detail)),
terminal_width,
),
code: 1,
},
}
}

Expand Down
64 changes: 58 additions & 6 deletions src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,16 +506,23 @@ pub fn adopt_stored_identity(snapshot: &mut InvocationSnapshot, domain: &DomainS
}

/// Resolve with the pre-T82 permissive rules used by read and project archive verbs.
/// Slash paths stay literal, and missing or ambiguous bare names stay literal.
/// Slash paths and bare names matching no project stay literal, so legacy relative
/// scopes and removed directories stay addressable. A bare name matching two or more
/// distinct projects refuses like `add` does, rather than silently matching nothing.
pub(crate) fn resolve_permissive_project_path(
token: &str,
domain: &DomainState,
snapshot: Option<&InvocationSnapshot>,
) -> String {
) -> Result<String, ProjectResolveError> {
if has_path_separator(token) {
return token.to_string();
return Ok(token.to_string());
}
let stored = stored_project_paths(domain);
// A legacy bare scope (`atlas`) is addressable only by its own spelling, so an exact
// stored match wins before a same-basename `/…/atlas` can make it ambiguous.
if stored.contains(token) {
return Ok(token.to_string());
}
let mut candidates = stored.clone();
if let Some(snapshot) = snapshot {
// Outside Git, the invocation directory was not historically a basename alias.
Expand All @@ -540,8 +547,12 @@ pub(crate) fn resolve_permissive_project_path(
}
}
match matches.as_slice() {
[path] => stored_spelling(domain, path).unwrap_or_else(|| path.clone()),
_ => token.to_string(),
[path] => Ok(stored_spelling(domain, path).unwrap_or_else(|| path.clone())),
[] => Ok(token.to_string()),
_ => {
matches.sort();
Err(ProjectResolveError::Ambiguous(matches))
}
}
}

Expand Down Expand Up @@ -692,7 +703,48 @@ mod portable_path_tests {
let domain = DomainState::new();
assert_eq!(
resolve_permissive_project_path(r"C:\work\tsk", &domain, None),
r"C:\work\tsk"
Ok(r"C:\work\tsk".to_string())
);
}

fn domain_with_scopes(paths: &[&str]) -> DomainState {
let mut domain = DomainState::new();
for path in paths {
domain
.create(
"fixture",
None,
TaskScope::Project {
path: (*path).to_string(),
},
crate::domain::ProvenanceOrigin::Manual,
None,
)
.expect("create project fixture");
}
domain
}

#[test]
fn permissive_resolution_keeps_a_legacy_bare_scope_addressable_beside_a_namesake() {
let domain = domain_with_scopes(&["atlas", "/projects/tsk-missing-namesake/atlas"]);
assert_eq!(
resolve_permissive_project_path("atlas", &domain, None),
Ok("atlas".to_string())
);
}

#[test]
fn permissive_resolution_refuses_a_basename_two_absolute_projects_share() {
let domain = domain_with_scopes(&[
"/projects/tsk-missing-b/atlas",
"/projects/tsk-missing-a/atlas",
]);
let error = resolve_permissive_project_path("atlas", &domain, None)
.expect_err("two absolute namesakes are ambiguous");
assert_eq!(
error.message("atlas"),
"project atlas is ambiguous: /projects/tsk-missing-a/atlas, /projects/tsk-missing-b/atlas"
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/setup_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,7 @@ mod tests {
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
assert_eq!(
hash, 0x0b2e_4132_6342_cac0,
hash, 0xaa9c_141f_5d1a_30fb,
"skills/tsk-cli/SKILL.md changed: refresh this hash pin, and bump `version:` only if this is the first skill edit since the last release"
);
}
Expand Down
Loading
Loading