A file-based task manager
0
fork

Configure Feed

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

Mirror workspace state to git refs when in a git repo

When tsk init runs inside a git repository, write a .tsk/git-backed marker
pointing at the .git directory. After every successful command, mirror task
files and metadata into refs/tsk/{tasks,archive,meta}/* as blobs. The on-disk
files remain authoritative; git refs are an additive durable mirror so the
file-based workflow keeps working unchanged outside of git repos.

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

+229 -3
-1
.tsk/index
··· 1 - tsk-32 Use git refs to store tasks 1776481730 2 1 tsk-15 Add link identification to tasks 1732594198 3 2 tsk-9 fix timestamp storage and parsing 1732594198 4 3 tsk-7 allow for creating tasks that don't go to top of stack 1732594198
-1
.tsk/tasks/tsk-32.tsk
··· 1 - ../archive/tsk-32.tsk
+206
src/git_store.rs
··· 1 + //! Mirror tsk workspace state into git refs under `refs/tsk/`. 2 + //! 3 + //! When `tsk init` is run inside a git repository, a `.tsk/git-backed` marker is 4 + //! written containing the absolute path to the `.git` directory. After every 5 + //! mutating command, [`sync`] walks the workspace and writes each task / index 6 + //! file as a git blob, updating refs to point at them. The on-disk files remain 7 + //! the source of truth; git refs are an additive durable mirror. 8 + 9 + use crate::errors::{Error, Result}; 10 + use std::path::{Path, PathBuf}; 11 + use std::process::{Command, Stdio}; 12 + 13 + const MARKER: &str = "git-backed"; 14 + const REF_PREFIX: &str = "refs/tsk"; 15 + 16 + pub fn detect_git_dir(start: &Path) -> Option<PathBuf> { 17 + crate::util::find_parent_with_dir(start.to_path_buf(), ".git").ok().flatten() 18 + } 19 + 20 + pub fn write_marker(tsk_dir: &Path, git_dir: &Path) -> Result<()> { 21 + std::fs::write(tsk_dir.join(MARKER), git_dir.to_string_lossy().as_bytes())?; 22 + Ok(()) 23 + } 24 + 25 + pub fn read_marker(tsk_dir: &Path) -> Option<PathBuf> { 26 + let s = std::fs::read_to_string(tsk_dir.join(MARKER)).ok()?; 27 + let trimmed = s.trim(); 28 + if trimmed.is_empty() { 29 + return None; 30 + } 31 + Some(PathBuf::from(trimmed)) 32 + } 33 + 34 + fn git(git_dir: &Path) -> Command { 35 + let mut c = Command::new("git"); 36 + c.env("GIT_DIR", git_dir); 37 + c 38 + } 39 + 40 + fn hash_object(git_dir: &Path, path: &Path) -> Result<String> { 41 + let out = git(git_dir) 42 + .args(["hash-object", "-w", "--"]) 43 + .arg(path) 44 + .stderr(Stdio::piped()) 45 + .output()?; 46 + if !out.status.success() { 47 + return Err(Error::Parse(format!( 48 + "git hash-object failed: {}", 49 + String::from_utf8_lossy(&out.stderr) 50 + ))); 51 + } 52 + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) 53 + } 54 + 55 + fn update_ref(git_dir: &Path, refname: &str, hash: &str) -> Result<()> { 56 + let status = git(git_dir) 57 + .args(["update-ref", refname, hash]) 58 + .stderr(Stdio::piped()) 59 + .status()?; 60 + if !status.success() { 61 + return Err(Error::Parse(format!("git update-ref {refname} failed"))); 62 + } 63 + Ok(()) 64 + } 65 + 66 + fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> { 67 + let _ = git(git_dir) 68 + .args(["update-ref", "-d", refname]) 69 + .stderr(Stdio::null()) 70 + .status()?; 71 + Ok(()) 72 + } 73 + 74 + fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<String>> { 75 + let out = git(git_dir) 76 + .args(["for-each-ref", "--format=%(refname)", prefix]) 77 + .output()?; 78 + if !out.status.success() { 79 + return Ok(Vec::new()); 80 + } 81 + Ok(String::from_utf8_lossy(&out.stdout) 82 + .lines() 83 + .map(|s| s.to_string()) 84 + .collect()) 85 + } 86 + 87 + /// Walk the workspace and mirror its contents to git refs. No-op if no marker. 88 + pub fn sync(tsk_dir: &Path) -> Result<()> { 89 + let Some(git_dir) = read_marker(tsk_dir) else { 90 + return Ok(()); 91 + }; 92 + if !git_dir.exists() { 93 + return Ok(()); 94 + } 95 + 96 + let mut wanted: std::collections::HashSet<String> = std::collections::HashSet::new(); 97 + 98 + // Mirror archive task contents. 99 + let archive_dir = tsk_dir.join("archive"); 100 + if archive_dir.exists() { 101 + for entry in std::fs::read_dir(&archive_dir)? { 102 + let entry = entry?; 103 + let path = entry.path(); 104 + if !path.is_file() { 105 + continue; 106 + } 107 + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { 108 + continue; 109 + }; 110 + if !name.starts_with("tsk-") || !name.ends_with(".tsk") { 111 + continue; 112 + } 113 + let hash = hash_object(&git_dir, &path)?; 114 + // Determine whether the task is currently active (has a symlink in tasks/). 115 + let active = tsk_dir.join("tasks").join(name).exists(); 116 + let bucket = if active { "tasks" } else { "archive" }; 117 + let refname = format!("{REF_PREFIX}/{bucket}/{}", name.trim_end_matches(".tsk")); 118 + update_ref(&git_dir, &refname, &hash)?; 119 + wanted.insert(refname); 120 + } 121 + } 122 + 123 + // Mirror top-level metadata files. 124 + for meta in ["index", "next", "cache", "remotes"] { 125 + let path = tsk_dir.join(meta); 126 + if path.is_file() { 127 + let hash = hash_object(&git_dir, &path)?; 128 + let refname = format!("{REF_PREFIX}/meta/{meta}"); 129 + update_ref(&git_dir, &refname, &hash)?; 130 + wanted.insert(refname); 131 + } 132 + } 133 + 134 + // Prune stale refs. 135 + for refname in list_refs(&git_dir, REF_PREFIX)? { 136 + if !wanted.contains(&refname) { 137 + delete_ref(&git_dir, &refname)?; 138 + } 139 + } 140 + 141 + Ok(()) 142 + } 143 + 144 + #[cfg(test)] 145 + mod test { 146 + use super::*; 147 + 148 + fn run(cmd: &mut Command) { 149 + let out = cmd.output().unwrap(); 150 + assert!(out.status.success(), "{:?}", out); 151 + } 152 + 153 + #[test] 154 + fn test_detect_and_sync() { 155 + let dir = tempfile::tempdir().unwrap(); 156 + let root = dir.path(); 157 + 158 + // Initialize a real git repo. 159 + run(Command::new("git").args(["init", "-q"]).current_dir(root)); 160 + 161 + // Create a .tsk workspace inside it. 162 + let tsk_dir = root.join(".tsk"); 163 + std::fs::create_dir(&tsk_dir).unwrap(); 164 + std::fs::create_dir(tsk_dir.join("tasks")).unwrap(); 165 + std::fs::create_dir(tsk_dir.join("archive")).unwrap(); 166 + 167 + let git_dir = detect_git_dir(&tsk_dir).expect("git dir found"); 168 + write_marker(&tsk_dir, &git_dir).unwrap(); 169 + assert_eq!(read_marker(&tsk_dir), Some(git_dir.clone())); 170 + 171 + // Create one active task and one archived task. 172 + std::fs::write(tsk_dir.join("archive/tsk-1.tsk"), "active title\n\nbody").unwrap(); 173 + std::os::unix::fs::symlink( 174 + PathBuf::from("../archive/tsk-1.tsk"), 175 + tsk_dir.join("tasks/tsk-1.tsk"), 176 + ) 177 + .unwrap(); 178 + std::fs::write(tsk_dir.join("archive/tsk-2.tsk"), "archived title\n\n").unwrap(); 179 + std::fs::write(tsk_dir.join("index"), "tsk-1\tactive title\t0\n").unwrap(); 180 + std::fs::write(tsk_dir.join("next"), "3\n").unwrap(); 181 + 182 + sync(&tsk_dir).unwrap(); 183 + 184 + let refs = list_refs(&git_dir, REF_PREFIX).unwrap(); 185 + assert!(refs.contains(&"refs/tsk/tasks/tsk-1".to_string())); 186 + assert!(refs.contains(&"refs/tsk/archive/tsk-2".to_string())); 187 + assert!(refs.contains(&"refs/tsk/meta/index".to_string())); 188 + assert!(refs.contains(&"refs/tsk/meta/next".to_string())); 189 + 190 + // Drop tsk-1 (remove symlink) and re-sync; ref should move to archive. 191 + std::fs::remove_file(tsk_dir.join("tasks/tsk-1.tsk")).unwrap(); 192 + sync(&tsk_dir).unwrap(); 193 + let refs = list_refs(&git_dir, REF_PREFIX).unwrap(); 194 + assert!(!refs.contains(&"refs/tsk/tasks/tsk-1".to_string())); 195 + assert!(refs.contains(&"refs/tsk/archive/tsk-1".to_string())); 196 + } 197 + 198 + #[test] 199 + fn test_sync_noop_without_marker() { 200 + let dir = tempfile::tempdir().unwrap(); 201 + let tsk_dir = dir.path().join(".tsk"); 202 + std::fs::create_dir(&tsk_dir).unwrap(); 203 + // No marker, no git dir — should not error. 204 + sync(&tsk_dir).unwrap(); 205 + } 206 + }
+10
src/main.rs
··· 1 1 mod attrs; 2 2 mod errors; 3 3 mod fzf; 4 + mod git_store; 4 5 mod stack; 5 6 mod task; 6 7 mod util; ··· 290 291 fn main() { 291 292 let cli = Cli::parse(); 292 293 let dir = cli.dir.unwrap_or(default_dir()); 294 + let sync_dir = dir.clone(); 293 295 let var_name = match cli.command { 294 296 Commands::Init => command_init(dir), 295 297 Commands::Push { edit, body, title } => command_push(dir, edit, body, title), ··· 324 326 Commands::Reopen { task_id } => command_reopen(dir, task_id), 325 327 }; 326 328 let result = var_name; 329 + // Best-effort mirror to git refs if the workspace is git-backed. Failures here 330 + // do not fail the user's command — the on-disk store remains authoritative. 331 + if result.is_ok() 332 + && let Ok(ws) = Workspace::from_path(sync_dir) 333 + && let Err(e) = ws.sync_git() 334 + { 335 + eprintln!("warning: git ref sync failed: {e}"); 336 + } 327 337 match result { 328 338 Ok(_) => exit(0), 329 339 Err(e) => {
+13 -1
src/workspace.rs
··· 6 6 use crate::errors::{Error, Result}; 7 7 use crate::stack::{StackItem, TaskStack}; 8 8 use crate::task::parse as parse_task; 9 - use crate::{fzf, util}; 9 + use crate::{fzf, git_store, util}; 10 10 use std::collections::{BTreeMap, HashSet, vec_deque}; 11 11 use std::ffi::OsString; 12 12 use std::fmt::Display; ··· 110 110 .open(tsk_dir.join("next"))?; 111 111 // initialize the next file with ID 1 112 112 next.write_all(b"1\n")?; 113 + // If we're inside a git repository, mark this workspace as git-backed so 114 + // future mutations mirror state into refs/tsk/. Outside of git this is a 115 + // no-op and the file-based store is the only persistence. 116 + if let Some(git_dir) = git_store::detect_git_dir(&path) { 117 + git_store::write_marker(&tsk_dir, &git_dir)?; 118 + } 113 119 Ok(()) 120 + } 121 + 122 + /// Mirror the workspace into git refs if this workspace was initialized 123 + /// inside a git repository. No-op otherwise. 124 + pub fn sync_git(&self) -> Result<()> { 125 + git_store::sync(&self.path) 114 126 } 115 127 116 128 pub fn from_path(path: PathBuf) -> Result<Self> {