···56565757 tsk help
58585959-tsk uses plain text files for all of its functionality. A workspace is a folder
6060-that contains a .tsk/ directory created with the `tsk init` command. The
6161-presence of a .tsk/ folder is searched recursively upwards until a filesystem
6262-boundary or root is encountered. This means you can nest workspaces and use
6363-folders to namespace tasks while also using tsk commands at any location within
6464-a workspace.
5959+tsk uses virtual text files tracked by special refs in a git repo for its
6060+functionality. Any git repo can be made into a tsk workspace.
65616662New tasks are created with the `tsk push` command. A title is always required,
6763but can be modified later. A unique identifier is selected automatically and a
+23-31
src/lib.rs
···11pub mod errors;
22mod fzf;
33-mod namespace;
43mod merge;
44+mod namespace;
55mod object;
66mod patch;
77-mod propvalue;
87mod properties;
88+mod propvalue;
99mod queue;
1010mod task;
1111mod workspace;
···162162 remote: Option<String>,
163163 },
164164 /// Push tsk refs to a git remote (default: origin).
165165- GitPush {
166166- remote: Option<String>,
167167- },
165165+ GitPush { remote: Option<String> },
168166 /// Fetch tsk refs from a git remote (default: origin) and reconcile
169167 /// divergent task histories. Default strategy is merge; pass --rebase
170168 /// to replay local-only commits onto the remote tip instead.
···308306 Current,
309307 /// Switch active namespace. With no name, fzf-picks from existing
310308 /// namespaces (plus a `<new>` sentinel for creating one on the fly).
311311- Switch { name: Option<String> },
309309+ Switch {
310310+ name: Option<String>,
311311+ },
312312 /// List every task bound in a namespace (defaults to active),
313313 /// regardless of which queue (if any) it's on. One row per id.
314314- Tasks { name: Option<String> },
314314+ Tasks {
315315+ name: Option<String>,
316316+ },
315317}
316318317319#[derive(Subcommand)]
···326328 },
327329 /// Switch active queue. With no name, fzf-picks from existing queues
328330 /// (plus a `<new>` sentinel for creating one on the fly).
329329- Switch { name: Option<String> },
331331+ Switch {
332332+ name: Option<String>,
333333+ },
330334}
331335332336#[derive(Subcommand)]
···387391 let picked: Option<String> = fzf::select(lines, ["--prompt=task> "])?;
388392 let picked = picked.ok_or(errors::Error::NoTasks)?;
389393 let id_str = picked.split('\t').next().unwrap_or("");
390390- let id: Id =
391391- parse_id(id_str).map_err(|e| errors::Error::Parse(e.to_string()))?;
394394+ let id: Id = parse_id(id_str).map_err(|e| errors::Error::Parse(e.to_string()))?;
392395 Ok(TaskIdentifier::Id(id))
393396 }
394397}
···446449 Commands::Swap => Workspace::from_path(dir)?.swap_top(),
447450 Commands::Rot => Workspace::from_path(dir)?.rot(),
448451 Commands::Tor => Workspace::from_path(dir)?.tor(),
449449- Commands::Prioritize { task_id } => {
450450- Workspace::from_path(dir)?.prioritize(task_id.into())
451451- }
452452+ Commands::Prioritize { task_id } => Workspace::from_path(dir)?.prioritize(task_id.into()),
452453 Commands::Deprioritize { task_id } => {
453454 Workspace::from_path(dir)?.deprioritize(task_id.into())
454455 }
···723724 .filter(|q| q != &cur)
724725 .collect();
725726 if candidates.is_empty() {
726726- return Err(errors::Error::Parse(
727727- "No other queues to assign to".into(),
728728- ));
727727+ return Err(errors::Error::Parse("No other queues to assign to".into()));
729728 }
730729 fzf::select::<_, String, _>(candidates, ["--prompt=assign to> "])?
731730 .ok_or_else(|| errors::Error::Parse("No queue selected".into()))
···926925 PropAction::Find { key, value } => {
927926 let key = match key {
928927 Some(k) => k,
929929- None => fzf::select::<_, String, _>(
930930- ws.property_keys()?,
931931- ["--prompt=key> "],
932932- )?
933933- .ok_or_else(|| errors::Error::Parse("No key selected".into()))?,
928928+ None => fzf::select::<_, String, _>(ws.property_keys()?, ["--prompt=key> "])?
929929+ .ok_or_else(|| errors::Error::Parse("No key selected".into()))?,
934930 };
935931 let value = match value {
936932 Some(v) if v == "<any>" => None,
···938934 None => {
939935 let mut choices = ws.property_values(&key)?;
940936 choices.insert(0, "<any>".to_string());
941941- let picked = fzf::select::<_, String, _>(
942942- choices,
943943- ["--prompt=value> "],
944944- )?
945945- .ok_or_else(|| errors::Error::Parse("No value selected".into()))?;
937937+ let picked = fzf::select::<_, String, _>(choices, ["--prompt=value> "])?
938938+ .ok_or_else(|| errors::Error::Parse("No value selected".into()))?;
946939 if picked == "<any>" {
947940 None
948941 } else {
···10731066}
1074106710751068fn strip_picker_marker(s: &str) -> &str {
10761076- s.strip_prefix("* ").or_else(|| s.strip_prefix(" ")).unwrap_or(s)
10691069+ s.strip_prefix("* ")
10701070+ .or_else(|| s.strip_prefix(" "))
10711071+ .unwrap_or(s)
10771072}
1078107310791074fn prompt_line(prompt: &str) -> Result<String> {
···1103109811041099 #[test]
11051100 fn picker_marks_current_and_appends_sentinel() {
11061106- let entries = picker_entries(
11071107- &["alpha".to_string(), "tsk".to_string()],
11081108- "tsk",
11091109- );
11011101+ let entries = picker_entries(&["alpha".to_string(), "tsk".to_string()], "tsk");
11101102 assert_eq!(entries, vec![" alpha", "* tsk", "<new>"]);
11111103 }
11121104
+22-17
src/merge.rs
···8888 }
8989 for r in repo.references_glob(&format!("{fetched_tasks}*"))? {
9090 let r = r?;
9191- if let Some(name) = r.name().and_then(|n| n.strip_prefix(fetched_tasks.as_str())) {
9191+ if let Some(name) = r
9292+ .name()
9393+ .and_then(|n| n.strip_prefix(fetched_tasks.as_str()))
9494+ {
9295 stables.insert(name.to_string());
9396 }
9497 }
···330333 format!("rebase-bind {name}")
331334 };
332335 let parents: Vec<&Commit> = vec![&local_commit, &remote_commit];
333333- let new_oid = repo.commit(
334334- None,
335335- &sig,
336336- &sig,
337337- &msg,
338338- &repo.find_tree(tree_oid)?,
339339- &parents,
340340- )?;
336336+ let new_oid =
337337+ repo.commit(None, &sig, &sig, &msg, &repo.find_tree(tree_oid)?, &parents)?;
341338 repo.reference(&namespace::refname(name), new_oid, true, &msg)?;
342339 Ok(Some(NamespaceReconciliation {
343340 namespace: name.to_string(),
···369366///
370367/// `can_pull`: 3-way bool. Local change wins if it differs from base;
371368/// otherwise take remote.
372372-pub fn reconcile_queue_refs(
373373- repo: &Repository,
374374- remote: &str,
375375-) -> Result<Vec<QueueReconciliation>> {
369369+pub fn reconcile_queue_refs(repo: &Repository, remote: &str) -> Result<Vec<QueueReconciliation>> {
376370 let fetched = format!("{}queues/", fetched_prefix(remote));
377371 let mut names: BTreeSet<String> = BTreeSet::new();
378372 for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? {
···448442 &parents,
449443 )?;
450444 repo.reference(&queue::refname(name), new_oid, true, "merge")?;
451451- Ok(Some(QueueReconciliation { name: name.to_string() }))
445445+ Ok(Some(QueueReconciliation {
446446+ name: name.to_string(),
447447+ }))
452448 }
453449 }
454450}
···468464 let in_remote = remote_set.contains(*s);
469465 // present in base → kept iff neither side removed it.
470466 // not in base → added by either side, keep.
471471- if in_base { in_local && in_remote } else { in_local || in_remote }
467467+ if in_base {
468468+ in_local && in_remote
469469+ } else {
470470+ in_local || in_remote
471471+ }
472472 })
473473 .collect();
474474···510510 local.can_pull
511511 };
512512513513- Queue { index, can_pull, inbox }
513513+ Queue {
514514+ index,
515515+ can_pull,
516516+ inbox,
517517+ }
514518}
515519516520/// After task refs are reconciled, copy every other fetched ref
···748752 let content_oid = repo.blob(b"v0").unwrap();
749753 let mut tb = repo.treebuilder(None).unwrap();
750754 tb.insert("content", content_oid, 0o100644).unwrap();
751751- tb.insert("title", repo.blob(b"v0").unwrap(), 0o100644).unwrap();
755755+ tb.insert("title", repo.blob(b"v0").unwrap(), 0o100644)
756756+ .unwrap();
752757 tb.insert("status", repo.blob(b"open\n").unwrap(), 0o100644)
753758 .unwrap();
754759 let tree_oid = tb.write().unwrap();
+2-12
src/namespace.rs
···136136137137/// Allocate the next human id, insert the binding, and persist. Returns the
138138/// human id assigned.
139139-pub fn assign_id(
140140- repo: &Repository,
141141- name: &str,
142142- stable: StableId,
143143- message: &str,
144144-) -> Result<u32> {
139139+pub fn assign_id(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<u32> {
145140 let mut ns = read(repo, name)?;
146141 let human = ns.next;
147142 ns.next += 1;
···178173}
179174180175/// Existing human id for `stable` in `name`, or a freshly-assigned one.
181181-pub fn ensure_bound(
182182- repo: &Repository,
183183- name: &str,
184184- stable: StableId,
185185- message: &str,
186186-) -> Result<u32> {
176176+pub fn ensure_bound(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<u32> {
187177 match human_for(repo, name, &stable)? {
188178 Some(h) => Ok(h),
189179 None => assign_id(repo, name, stable, message),
+17-13
src/object.rs
···9999 let stable = StableId(content_oid.to_string());
100100 let tree_oid = build_tree(repo, content_oid, &task.properties)?;
101101 let sig = signature(repo);
102102- let commit = repo.commit(
103103- None,
104104- &sig,
105105- &sig,
106106- message,
107107- &repo.find_tree(tree_oid)?,
108108- &[],
109109- )?;
102102+ let commit = repo.commit(None, &sig, &sig, message, &repo.find_tree(tree_oid)?, &[])?;
110103 repo.reference(&stable.refname(), commit, true, message)?;
111104 Ok(stable)
112105}
···207200 let dir = tempfile::tempdir().unwrap();
208201 let repo = init_repo(dir.path());
209202 let mut t = Task::new("Hello\n\nbody text");
210210- t.properties
211211- .insert("priority".into(), vec!["high".into()]);
203203+ t.properties.insert("priority".into(), vec!["high".into()]);
212204 t.properties
213205 .insert("tag".into(), vec!["alpha".into(), "beta".into()]);
214206 let id = create(&repo, &t, "create").unwrap();
···229221 t2.content = "v2".into();
230222 update(&repo, &id, &t2, "edit").unwrap();
231223 // Two commits in the chain.
232232- let head = repo.find_reference(&id.refname()).unwrap().target().unwrap();
224224+ let head = repo
225225+ .find_reference(&id.refname())
226226+ .unwrap()
227227+ .target()
228228+ .unwrap();
233229 let head_commit = repo.find_commit(head).unwrap();
234230 assert_eq!(head_commit.parent_count(), 1);
235231 let read_back = read(&repo, &id).unwrap().unwrap();
···242238 let repo = init_repo(dir.path());
243239 let t = Task::new("same");
244240 let id = create(&repo, &t, "create").unwrap();
245245- let head1 = repo.find_reference(&id.refname()).unwrap().target().unwrap();
241241+ let head1 = repo
242242+ .find_reference(&id.refname())
243243+ .unwrap()
244244+ .target()
245245+ .unwrap();
246246 update(&repo, &id, &t, "noop").unwrap();
247247- let head2 = repo.find_reference(&id.refname()).unwrap().target().unwrap();
247247+ let head2 = repo
248248+ .find_reference(&id.refname())
249249+ .unwrap()
250250+ .target()
251251+ .unwrap();
248252 assert_eq!(head1, head2);
249253 }
250254
+19-22
src/patch.rs
···5757 pub bind: Option<(String, u32)>,
5858}
59596060-pub fn export_task(
6161- repo: &Repository,
6262- stable: &StableId,
6363- opts: &ExportOpts,
6464-) -> Result<String> {
6060+pub fn export_task(repo: &Repository, stable: &StableId, opts: &ExportOpts) -> Result<String> {
6561 let r = repo.find_reference(&stable.refname())?;
6666- let tip = r.target().ok_or_else(|| Error::Parse("task ref empty".into()))?;
6262+ let tip = r
6363+ .target()
6464+ .ok_or_else(|| Error::Parse("task ref empty".into()))?;
6765 // Collect root → tip.
6866 let mut chain: Vec<Oid> = Vec::new();
6967 let mut cur = Some(repo.find_commit(tip)?);
···139137 writeln!(
140138 out,
141139 "X-Tsk-Parent: {}",
142142- parent.map(|o| o.to_string()).unwrap_or_else(|| "none".into())
140140+ parent
141141+ .map(|o| o.to_string())
142142+ .unwrap_or_else(|| "none".into())
143143 )
144144 .unwrap();
145145 if let Some((ns, human)) = bind {
···163163 }
164164 let blob = entry.to_object(repo)?.peel_to_blob()?;
165165 let bytes = blob.content();
166166- let as_str =
167167- std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?;
166166+ let as_str = std::str::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?;
168167 let mangled = mangle_from(as_str);
169168 writeln!(out, "file: {name}").unwrap();
170169 writeln!(out, "size: {}", mangled.len()).unwrap();
···286285 // history records who applied the import while preserving authorship.
287286 let author = Signature::new(&e.author_name, &e.author_email, &e.when)?;
288287 let committer = crate::object::signature(repo);
289289- let parents: Vec<git2::Commit> = prev.into_iter().map(|o| repo.find_commit(o).unwrap()).collect();
288288+ let parents: Vec<git2::Commit> = prev
289289+ .into_iter()
290290+ .map(|o| repo.find_commit(o).unwrap())
291291+ .collect();
290292 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
291293 let commit_oid = repo.commit(
292294 None,
···438440 if rest.len() < size + 1 {
439441 return Err(Error::Parse("truncated file body".into()));
440442 }
441441- let mangled = std::str::from_utf8(&rest[..size])
442442- .map_err(|e| Error::Parse(e.to_string()))?;
443443+ let mangled =
444444+ std::str::from_utf8(&rest[..size]).map_err(|e| Error::Parse(e.to_string()))?;
443445 if rest[size] != b'\n' {
444446 return Err(Error::Parse("missing newline after file body".into()));
445447 }
···530532 fn tamper_detected_via_stable_id_check() {
531533 let dir = tempfile::tempdir().unwrap();
532534 let src = init_repo(dir.path());
533533- let stable =
534534- object::create(&src, &Task::new("original content"), "create").unwrap();
535535+ let stable = object::create(&src, &Task::new("original content"), "create").unwrap();
535536 let mbox = export_task(&src, &stable, &ExportOpts { bind: None }).unwrap();
536537 // Flip the content body without updating the stable id header.
537538 // Equal-length substitution so size-prefix parsing still aligns; only
···554555 let s = "preamble\nFrom the desk of...\n>From me\nbody\n";
555556 let mangled = mangle_from(s);
556557 assert_eq!(
557557- mangled,
558558- "preamble\n>From the desk of...\n>>From me\nbody\n",
558558+ mangled, "preamble\n>From the desk of...\n>>From me\nbody\n",
559559 "every ^>*From line gets one extra '>'",
560560 );
561561 assert_eq!(unmangle_from(&mangled), s);
···607607 // Alice creates → exports. Bob imports.
608608 let alice_dir = tempfile::tempdir().unwrap();
609609 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x");
610610- let stable =
611611- object::create(&alice_repo, &Task::new("from alice"), "create").unwrap();
610610+ let stable = object::create(&alice_repo, &Task::new("from alice"), "create").unwrap();
612611 let mbox = export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap();
613612614613 let bob_dir = tempfile::tempdir().unwrap();
···631630 // second commit authored by Bob.
632631 let alice_dir = tempfile::tempdir().unwrap();
633632 let alice_repo = init_repo_as(alice_dir.path(), "Alice", "a@x");
634634- let stable =
635635- object::create(&alice_repo, &Task::new("from alice"), "create").unwrap();
636636- let alice_mbox =
637637- export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap();
633633+ let stable = object::create(&alice_repo, &Task::new("from alice"), "create").unwrap();
634634+ let alice_mbox = export_task(&alice_repo, &stable, &ExportOpts { bind: None }).unwrap();
638635639636 let bob_dir = tempfile::tempdir().unwrap();
640637 let bob_repo = init_repo_as(bob_dir.path(), "Bob", "b@x");