A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

tsk remote: manage non-origin remotes + per-clone default

`tsk remote` subcommands:

- `list` — wraps `git remote`
- `default` — print the active default
- `set-default <n>` — persist the default to <git-dir>/tsk/remote
- `add <name> <url>` — `git remote add` + configure tsk refspecs
- `remove <name>` — `git remote remove`

The default remote is a per-clone selector parallel to `namespace`
and `queue`, stored at `<git-dir>/tsk/remote`. Falls back to
`origin` when absent. `tsk git-push`, `tsk git-pull`, `tsk
git-setup`, and the auto-push paths after assign/accept/reject all
consult it when no explicit `<remote>` / `-R <remote>` is given.

ARCHITECTURE.md and AGENTS.md updated to mention the new selector
and the new commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+111 -12
+3 -1
AGENTS.md
··· 91 91 Local refs aren't visible to the user until pushed: 92 92 93 93 ``` 94 - tsk git-push # push refs/tsk/* to origin (also runs after assign/reject) 94 + tsk remote add <name> <url> # add a git remote + configure tsk refspecs in one step 95 + tsk remote set-default <name> # use this remote for git-push/pull and the auto-push paths 96 + tsk git-push # push refs/tsk/* to default remote (also runs after assign/reject) 95 97 tsk git-pull # fetch + reconcile divergent task histories 96 98 tsk git-pull --rebase # replay local commits on the remote tip instead of merging 97 99 tsk inbox # auto-pulls then lists pending inbox items
+2 -1
ARCHITECTURE.md
··· 28 28 │ └── properties/<key> # tree: { <stable-id> → blob of values } 29 29 └── tsk/ 30 30 ├── namespace # active namespace (defaults to "tsk") 31 - └── queue # active queue (defaults to "tsk") 31 + ├── queue # active queue (defaults to "tsk") 32 + └── remote # default git remote for sync (defaults to "origin") 32 33 ``` 33 34 34 35 Every category except `tasks` is a tree; per-task histories are
+54 -10
src/lib.rs
··· 230 230 #[command(subcommand)] 231 231 action: QueueAction, 232 232 }, 233 + /// Manage git remotes that carry tsk refs. 234 + Remote { 235 + #[command(subcommand)] 236 + action: RemoteAction, 237 + }, 233 238 /// Switch active namespace (shorthand). With no name, fzf-picks from 234 239 /// existing namespaces (plus a `<new>` sentinel for creating one on 235 240 /// the fly). ··· 318 323 Switch { name: String }, 319 324 } 320 325 326 + #[derive(Subcommand)] 327 + enum RemoteAction { 328 + /// List configured git remotes (delegates to `git remote`). 329 + List, 330 + /// Print the active default remote (the one used when no `-R` is given). 331 + Default, 332 + /// Add a git remote and configure the tsk refspecs on it in one step. 333 + Add { name: String, url: String }, 334 + /// Remove a git remote. 335 + Remove { name: String }, 336 + /// Persist the active default remote for this clone. 337 + SetDefault { name: String }, 338 + } 339 + 321 340 #[derive(Args)] 322 341 #[group(required = true, multiple = false)] 323 342 struct Title { ··· 382 401 } 383 402 } 384 403 385 - fn effective_remote(supplied: Option<String>) -> Option<String> { 404 + fn effective_remote(ws: &Workspace, supplied: Option<String>) -> Option<String> { 386 405 supplied 387 406 .map(|s| if s.is_empty() { None } else { Some(s) }) 388 - .unwrap_or_else(|| Some("origin".to_string())) 407 + .unwrap_or_else(|| Some(ws.default_remote())) 389 408 } 390 409 391 410 /// Scoped push (best-effort, silent on `-R ""`). 392 411 fn auto_push_refs(ws: &Workspace, remote: Option<String>, refs: Vec<String>) { 393 - if let Some(r) = effective_remote(remote) { 412 + if let Some(r) = effective_remote(ws, remote) { 394 413 let _ = ws.git_push_refs(&r, &refs); 395 414 } 396 415 } ··· 454 473 Ok(()) 455 474 } 456 475 Commands::GitSetup { remote } => { 457 - let r = remote.unwrap_or_else(|| "origin".to_string()); 458 - Workspace::from_path(dir)?.configure_git_remote_refspecs(&r) 476 + let ws = Workspace::from_path(dir)?; 477 + let r = remote.unwrap_or_else(|| ws.default_remote()); 478 + ws.configure_git_remote_refspecs(&r) 459 479 } 460 480 Commands::GitPush { remote } => { 461 - let r = remote.unwrap_or_else(|| "origin".to_string()); 462 - Workspace::from_path(dir)?.git_push(&r) 481 + let ws = Workspace::from_path(dir)?; 482 + let r = remote.unwrap_or_else(|| ws.default_remote()); 483 + ws.git_push(&r) 463 484 } 464 485 Commands::GitPull { remote, rebase } => { 465 - let r = remote.unwrap_or_else(|| "origin".to_string()); 486 + let ws = Workspace::from_path(dir)?; 487 + let r = remote.unwrap_or_else(|| ws.default_remote()); 466 488 let strategy = if rebase { 467 489 merge::Strategy::Rebase 468 490 } else { 469 491 merge::Strategy::Merge 470 492 }; 471 - let outcome = Workspace::from_path(dir)?.git_pull_with_strategy(&r, strategy)?; 493 + let outcome = ws.git_pull_with_strategy(&r, strategy)?; 472 494 for rec in &outcome.tasks { 473 495 if !matches!(rec.kind, merge::ReconKind::Unchanged) { 474 496 let short = &rec.stable.0[..12.min(rec.stable.0.len())]; ··· 501 523 Commands::Prop { action } => command_prop(dir, action), 502 524 Commands::Namespace { action } => command_namespace(dir, action), 503 525 Commands::Queue { action } => command_queue(dir, action), 526 + Commands::Remote { action } => command_remote(dir, action), 504 527 Commands::Switch { name } => { 505 528 resolve_and_switch_namespace(&Workspace::from_path(dir)?, name) 506 529 } ··· 699 722 700 723 fn command_inbox(dir: PathBuf, remote: Option<String>) -> Result<()> { 701 724 let ws = Workspace::from_path(dir)?; 702 - if let Some(r) = effective_remote(remote) { 725 + if let Some(r) = effective_remote(&ws, remote) { 703 726 let refs = ws.refs_for_inbox_pull(); 704 727 let _ = ws.git_fetch_refs(&r, &refs); 705 728 } ··· 931 954 for entry in ws.list_namespace_tasks(&target)? { 932 955 println!("{}\t{}", entry.id, entry.title); 933 956 } 957 + } 958 + } 959 + Ok(()) 960 + } 961 + 962 + fn command_remote(dir: PathBuf, action: RemoteAction) -> Result<()> { 963 + let ws = Workspace::from_path(dir)?; 964 + match action { 965 + RemoteAction::List => print_lines(ws.git_remotes()?), 966 + RemoteAction::Default => println!("{}", ws.default_remote()), 967 + RemoteAction::Add { name, url } => { 968 + ws.git_remote_add(&name, &url)?; 969 + println!("Added remote '{name}' (refspecs configured)"); 970 + } 971 + RemoteAction::Remove { name } => { 972 + ws.git_remote_remove(&name)?; 973 + println!("Removed remote '{name}'"); 974 + } 975 + RemoteAction::SetDefault { name } => { 976 + ws.set_default_remote(&name)?; 977 + println!("Default remote set to '{name}'"); 934 978 } 935 979 } 936 980 Ok(())
+52
src/workspace.rs
··· 34 34 35 35 const NAMESPACE_FILE: &str = "namespace"; 36 36 const QUEUE_FILE: &str = "queue"; 37 + const REMOTE_FILE: &str = "remote"; 38 + pub const DEFAULT_REMOTE: &str = "origin"; 37 39 /// Auto-managed property holding the task's lifecycle state. Set to 38 40 /// `STATUS_OPEN` on creation and flipped to `STATUS_DONE` by [`Workspace::drop`]. 39 41 pub const STATUS_KEY: &str = "status"; ··· 204 206 queue::validate_name(name)?; 205 207 std::fs::write(self.path.join(QUEUE_FILE), name.as_bytes())?; 206 208 Ok(()) 209 + } 210 + 211 + /// Persist a clone-local default remote so `tsk git-push` / 212 + /// `tsk git-pull` (and the auto-push paths) target it without an 213 + /// explicit `<remote>` arg. 214 + pub fn default_remote(&self) -> String { 215 + self.read_selector(REMOTE_FILE, DEFAULT_REMOTE) 216 + } 217 + 218 + pub fn set_default_remote(&self, name: &str) -> Result<()> { 219 + std::fs::write(self.path.join(REMOTE_FILE), name.as_bytes())?; 220 + Ok(()) 221 + } 222 + 223 + /// Wrap `git remote add` and immediately configure the tsk refspecs 224 + /// on it. Idempotent on the refspec side; errors on duplicate remote. 225 + pub fn git_remote_add(&self, name: &str, url: &str) -> Result<()> { 226 + if !self 227 + .git() 228 + .args(["remote", "add", name, url]) 229 + .status()? 230 + .success() 231 + { 232 + return Err(Error::Parse(format!("git remote add {name} failed"))); 233 + } 234 + self.configure_git_remote_refspecs(name) 235 + } 236 + 237 + pub fn git_remote_remove(&self, name: &str) -> Result<()> { 238 + if !self 239 + .git() 240 + .args(["remote", "remove", name]) 241 + .status()? 242 + .success() 243 + { 244 + return Err(Error::Parse(format!("git remote remove {name} failed"))); 245 + } 246 + Ok(()) 247 + } 248 + 249 + pub fn git_remotes(&self) -> Result<Vec<String>> { 250 + let out = self.git().arg("remote").output()?; 251 + if !out.status.success() { 252 + return Err(Error::Parse("git remote failed".into())); 253 + } 254 + Ok(String::from_utf8_lossy(&out.stdout) 255 + .lines() 256 + .map(|s| s.trim().to_string()) 257 + .filter(|s| !s.is_empty()) 258 + .collect()) 207 259 } 208 260 209 261 pub fn list_namespaces(&self) -> Result<Vec<String>> {