A file-based task manager
0
fork

Configure Feed

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

Real conflict handling for tsk git-push / git-pull

Push and pull now reconcile through a per-remote shadow at
refs/remotes-tsk/<remote>/* and refuse to silently overwrite a
concurrent edit.

Push:
- Fetch the remote into the shadow first so leases match its current
state.
- For each refs/tsk/* ref, skip when local matches shadow; otherwise
push with `--force-with-lease=<ref>:<shadow-oid>` so a concurrent
push fails the lease and aborts ours.
- After a successful push, refresh the shadow.

Pull:
- Fetch into refs/remotes-tsk/<remote>/* (force, since this is our
private mirror).
- For each fetched ref, look at three OIDs — local, the shadow's
pre-fetch value (the merge base), and the new remote — and decide:
local missing → take remote
local == new remote → no-op
local unchanged from base → take remote
remote unchanged from base → keep local
both moved + mergeable → 3-way merge
both moved + not mergeable → conflict
- Mergeable refs are `log/*` (union sorted by leading timestamp) and
`index` (union preserving local order, append remote-only items).
- Conflicts surface as a clear error listing the divergent refs;
local state is preserved so the user can resolve and retry.

Test covers the happy path (A pushes a new task, B edits a different
task locally, B pulls and gets both sets of changes), and the conflict
path (A and B both edit the same task body, B pulls and gets a
conflict naming the offending ref while keeping their local edit).

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

+353 -4
+353 -4
src/workspace.rs
··· 64 64 } 65 65 } 66 66 67 + enum PullAction { 68 + /// Local already matches remote — nothing to do. 69 + Skip, 70 + /// Take the remote OID verbatim (local missing or unchanged since last sync). 71 + Take, 72 + /// Both sides moved; the ref is union-mergeable, do a 3-way merge. 73 + Merge, 74 + /// Both sides moved and the ref is not auto-mergeable. 75 + Conflict, 76 + } 77 + 78 + fn is_mergeable_key(rel: &str) -> bool { 79 + rel.starts_with("log/") || rel.ends_with("/log") || rel == "index" || rel.ends_with("/index") 80 + } 81 + 82 + fn resolve_pull( 83 + local: Option<git2::Oid>, 84 + old_remote: Option<git2::Oid>, 85 + new_remote: git2::Oid, 86 + rel: &str, 87 + ) -> PullAction { 88 + match local { 89 + None => PullAction::Take, 90 + Some(l) if l == new_remote => PullAction::Skip, 91 + Some(l) => match old_remote { 92 + // Local hasn't moved since last sync; remote did → take remote. 93 + Some(o) if o == l => PullAction::Take, 94 + // Remote hasn't moved since last sync; local did → keep local. 95 + Some(o) if o == new_remote => PullAction::Skip, 96 + // Either no shared base, or both moved. 97 + _ => { 98 + if is_mergeable_key(rel) { 99 + PullAction::Merge 100 + } else { 101 + PullAction::Conflict 102 + } 103 + } 104 + }, 105 + } 106 + } 107 + 108 + /// Union of two append-only logs, sorted by the leading unix timestamp on 109 + /// each line. Duplicate lines collapse. 110 + fn merge_log(local: &str, remote: &str) -> String { 111 + let mut all: Vec<&str> = local.lines().chain(remote.lines()).collect(); 112 + all.sort_by_key(|l| { 113 + l.split('\t') 114 + .next() 115 + .and_then(|t| t.parse::<u64>().ok()) 116 + .unwrap_or(0) 117 + }); 118 + all.dedup(); 119 + let mut out = all.join("\n"); 120 + if !out.is_empty() { 121 + out.push('\n'); 122 + } 123 + out 124 + } 125 + 126 + /// Union of two stack indexes preserving local order; remote-only items get 127 + /// appended in their relative order. Items are identified by their leading 128 + /// `tsk-N` field. 129 + fn merge_index(local: &str, remote: &str) -> String { 130 + let key = |line: &str| line.split('\t').next().unwrap_or("").to_string(); 131 + let mut seen: HashSet<String> = HashSet::new(); 132 + let mut out = String::new(); 133 + for line in local.lines() { 134 + if line.trim().is_empty() { 135 + continue; 136 + } 137 + seen.insert(key(line)); 138 + out.push_str(line); 139 + out.push('\n'); 140 + } 141 + for line in remote.lines() { 142 + if line.trim().is_empty() { 143 + continue; 144 + } 145 + if seen.insert(key(line)) { 146 + out.push_str(line); 147 + out.push('\n'); 148 + } 149 + } 150 + out 151 + } 152 + 67 153 /// Reject namespace names that contain `/` or other characters problematic in 68 154 /// a git ref path. 69 155 fn validate_namespace(name: &str) -> Result<()> { ··· 635 721 Ok(()) 636 722 } 637 723 638 - /// Push every refs/tsk/* ref to the given remote. 724 + /// Push every refs/tsk/* ref to the given remote, using the per-ref 725 + /// `--force-with-lease=<ref>:<expected>` so a concurrent push on the 726 + /// remote causes our push to fail rather than silently overwrite. 727 + /// The expected OID is taken from the local 728 + /// `refs/remotes-tsk/<remote>/*` shadow, which is refreshed first. 729 + /// After a successful push, the shadow is updated to match the new state. 639 730 pub fn git_push_refs(&self, remote: &str) -> Result<()> { 640 - self.run_git(&["push", remote, "refs/tsk/*:refs/tsk/*"]) 731 + let _ = self.require_git_dir()?; 732 + // Refresh the shadow so leases match the remote's current state. 733 + let _ = self 734 + .git_cmd()? 735 + .args([ 736 + "fetch", 737 + remote, 738 + &format!("+refs/tsk/*:refs/remotes-tsk/{remote}/*"), 739 + ]) 740 + .status()?; 741 + let shadow: BTreeMap<String, git2::Oid> = self.read_shadow(remote)?.into_iter().collect(); 742 + 743 + let repo = git2::Repository::open(self.require_git_dir()?)?; 744 + let mut leases: Vec<String> = Vec::new(); 745 + let mut refspecs: Vec<String> = Vec::new(); 746 + for r in repo.references()? { 747 + let r = r?; 748 + let Some(name) = r.name() else { continue }; 749 + let Some(rest) = name.strip_prefix("refs/tsk/") else { 750 + continue; 751 + }; 752 + let Some(local_oid) = r.target() else { 753 + continue; 754 + }; 755 + if shadow.get(rest) == Some(&local_oid) { 756 + continue; // up to date 757 + } 758 + if let Some(expected) = shadow.get(rest) { 759 + leases.push(format!("--force-with-lease=refs/tsk/{rest}:{expected}")); 760 + } 761 + refspecs.push(format!("refs/tsk/{rest}:refs/tsk/{rest}")); 762 + } 763 + if refspecs.is_empty() { 764 + return Ok(()); 765 + } 766 + let mut args: Vec<String> = vec!["push".to_string(), remote.to_string()]; 767 + args.extend(leases); 768 + args.extend(refspecs); 769 + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); 770 + self.run_git(&argv)?; 771 + self.update_remote_shadow(remote)?; 772 + Ok(()) 641 773 } 642 774 643 - /// Fetch every refs/tsk/* ref from the given remote, overwriting locally. 775 + /// Reconcile every refs/tsk/* ref with the remote. Fetch lands in 776 + /// `refs/remotes-tsk/<remote>/*` (force, since it's our private mirror); 777 + /// then for each ref we look at three OIDs — local, the previous 778 + /// fetched-from-remote (the merge base), and the new remote — and pick: 779 + /// 780 + /// - local untouched since last sync → take remote 781 + /// - remote untouched since last sync → keep local 782 + /// - both moved, ref is union-mergeable (`log/*`, `index`) → 3-way merge 783 + /// - both moved, ref is not union-mergeable → conflict; abort with a 784 + /// list of the offending refs. The fetch shadow is updated either way 785 + /// so a re-run after manual resolution sees the right base. 644 786 pub fn git_pull_refs(&self, remote: &str) -> Result<()> { 645 - self.run_git(&["fetch", remote, "+refs/tsk/*:refs/tsk/*"]) 787 + let _ = self.require_git_dir()?; 788 + // Snapshot pre-fetch shadow so we know the previous remote position. 789 + let pre_fetch: BTreeMap<String, git2::Oid> = 790 + self.read_shadow(remote)?.into_iter().collect(); 791 + // Fetch (force, into our private shadow only). 792 + self.run_git(&[ 793 + "fetch", 794 + remote, 795 + &format!("+refs/tsk/*:refs/remotes-tsk/{remote}/*"), 796 + ])?; 797 + let post_fetch: BTreeMap<String, git2::Oid> = 798 + self.read_shadow(remote)?.into_iter().collect(); 799 + 800 + let repo = git2::Repository::open(self.require_git_dir()?)?; 801 + let mut conflicts: Vec<String> = Vec::new(); 802 + for (rel, &new_remote) in &post_fetch { 803 + let local_refname = format!("refs/tsk/{rel}"); 804 + let local_oid = repo 805 + .find_reference(&local_refname) 806 + .ok() 807 + .and_then(|r| r.target()); 808 + let old_remote = pre_fetch.get(rel).copied(); 809 + match resolve_pull(local_oid, old_remote, new_remote, rel) { 810 + PullAction::Skip => {} 811 + PullAction::Take => { 812 + repo.reference(&local_refname, new_remote, true, "tsk pull")?; 813 + } 814 + PullAction::Merge => { 815 + let merged = self.merge_blob(&repo, rel, local_oid, new_remote)?; 816 + repo.reference(&local_refname, merged, true, "tsk pull merge")?; 817 + } 818 + PullAction::Conflict => conflicts.push(rel.clone()), 819 + } 820 + } 821 + if !conflicts.is_empty() { 822 + return Err(Error::Parse(format!( 823 + "pull conflicts on: {}\n(local and remote both diverged from the last sync; \ 824 + these refs aren't auto-mergeable. Resolve manually with `git update-ref` \ 825 + or by editing the corresponding tsk objects.)", 826 + conflicts.join(", ") 827 + ))); 828 + } 829 + Ok(()) 830 + } 831 + 832 + /// Read every `refs/remotes-tsk/<remote>/*` and return `(rel, oid)` where 833 + /// `rel` is the path under that prefix (matches the local-side `rel` used 834 + /// against `refs/tsk/`). 835 + fn read_shadow(&self, remote: &str) -> Result<Vec<(String, git2::Oid)>> { 836 + let repo = git2::Repository::open(self.require_git_dir()?)?; 837 + let prefix = format!("refs/remotes-tsk/{remote}/"); 838 + let mut out = Vec::new(); 839 + for r in repo.references()? { 840 + let r = r?; 841 + if let Some(name) = r.name() 842 + && let Some(rest) = name.strip_prefix(&prefix) 843 + && let Some(oid) = r.target() 844 + { 845 + out.push((rest.to_string(), oid)); 846 + } 847 + } 848 + Ok(out) 849 + } 850 + 851 + /// After a successful push, copy current local `refs/tsk/*` OIDs into 852 + /// `refs/remotes-tsk/<remote>/*` so the next pull's merge base is correct. 853 + fn update_remote_shadow(&self, remote: &str) -> Result<()> { 854 + let repo = git2::Repository::open(self.require_git_dir()?)?; 855 + let prefix = "refs/tsk/"; 856 + let dest_prefix = format!("refs/remotes-tsk/{remote}/"); 857 + let updates: Vec<(String, git2::Oid)> = repo 858 + .references()? 859 + .filter_map(|r| { 860 + let r = r.ok()?; 861 + let name = r.name()?.to_string(); 862 + let oid = r.target()?; 863 + let rest = name.strip_prefix(prefix)?.to_string(); 864 + Some((rest, oid)) 865 + }) 866 + .collect(); 867 + for (rest, oid) in updates { 868 + repo.reference( 869 + &format!("{dest_prefix}{rest}"), 870 + oid, 871 + true, 872 + "tsk push shadow", 873 + )?; 874 + } 875 + Ok(()) 876 + } 877 + 878 + /// Read a blob by its OID. 879 + fn read_oid(&self, repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> { 880 + let blob = repo.find_blob(oid)?; 881 + Ok(blob.content().to_vec()) 882 + } 883 + 884 + /// Three-way merge for union-mergeable refs (`log/*` and `index`). 885 + fn merge_blob( 886 + &self, 887 + repo: &git2::Repository, 888 + rel: &str, 889 + local: Option<git2::Oid>, 890 + remote: git2::Oid, 891 + ) -> Result<git2::Oid> { 892 + let local_bytes = match local { 893 + Some(o) => self.read_oid(repo, o)?, 894 + None => Vec::new(), 895 + }; 896 + let remote_bytes = self.read_oid(repo, remote)?; 897 + let local_text = String::from_utf8_lossy(&local_bytes); 898 + let remote_text = String::from_utf8_lossy(&remote_bytes); 899 + let merged = if rel.starts_with("log/") || rel.ends_with("/log") { 900 + merge_log(&local_text, &remote_text) 901 + } else { 902 + // index: union of stack item lines, preserving local order then 903 + // appending remote-only items in their relative order. 904 + merge_index(&local_text, &remote_text) 905 + }; 906 + Ok(repo.blob(merged.as_bytes())?) 646 907 } 647 908 648 909 /// Configure git so future `git push <remote>` / `git fetch <remote>` ··· 1404 1665 Workspace::init(dir.path().to_path_buf()).unwrap(); 1405 1666 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); 1406 1667 run_every_command(&ws); 1668 + } 1669 + 1670 + /// Two clones diverge: clone A pushes, clone B edits locally, then B 1671 + /// pulls. Mergeable refs (index, log) auto-merge; a divergent task body 1672 + /// is reported as a conflict. 1673 + #[test] 1674 + fn test_pull_resolves_or_reports_conflicts() { 1675 + let dir = tempfile::tempdir().unwrap(); 1676 + let remote_dir = dir.path().join("remote.git"); 1677 + let a_dir = dir.path().join("a"); 1678 + let b_dir = dir.path().join("b"); 1679 + std::fs::create_dir_all(&remote_dir).unwrap(); 1680 + std::fs::create_dir_all(&a_dir).unwrap(); 1681 + std::fs::create_dir_all(&b_dir).unwrap(); 1682 + 1683 + let s = std::process::Command::new("git") 1684 + .args(["init", "--bare", "-q"]) 1685 + .current_dir(&remote_dir) 1686 + .status() 1687 + .unwrap(); 1688 + assert!(s.success()); 1689 + 1690 + let init_clone = |path: &std::path::Path| { 1691 + run_git_init(path); 1692 + std::process::Command::new("git") 1693 + .args(["remote", "add", "origin"]) 1694 + .arg(&remote_dir) 1695 + .current_dir(path) 1696 + .status() 1697 + .unwrap(); 1698 + Workspace::init(path.to_path_buf()).unwrap(); 1699 + Workspace::from_path(path.to_path_buf()).unwrap() 1700 + }; 1701 + let a = init_clone(&a_dir); 1702 + let b = init_clone(&b_dir); 1703 + 1704 + // A pushes a task that B will start from. 1705 + let t = a.new_task("shared".into(), "v0".into()).unwrap(); 1706 + let id = t.id; 1707 + a.push_task(t).unwrap(); 1708 + a.git_push_refs("origin").unwrap(); 1709 + b.git_pull_refs("origin").unwrap(); 1710 + assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().title, "shared"); 1711 + 1712 + // Both diverge: 1713 + // - A pushes a second task (touches index + new tasks/2 + log/2). 1714 + // - B edits the original task body locally (touches tasks/1 + log/1). 1715 + let t2 = a.new_task("a-only".into(), "v1".into()).unwrap(); 1716 + let a2_id = t2.id; 1717 + a.push_task(t2).unwrap(); 1718 + a.git_push_refs("origin").unwrap(); 1719 + 1720 + let mut local = b.task(TaskIdentifier::Id(id)).unwrap(); 1721 + local.body = "v0-edit".into(); 1722 + b.save_task(&local).unwrap(); 1723 + 1724 + // B pulls: tasks/<a2_id> is new → take. index moved both sides → merge. 1725 + // log/<id> moved both sides → merge. tasks/1 moved on B only → keep 1726 + // local. So no conflicts. 1727 + b.git_pull_refs("origin").unwrap(); 1728 + // B's edit survived… 1729 + assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().body, "v0-edit"); 1730 + // …and A's new task arrived. 1731 + assert_eq!(b.task(TaskIdentifier::Id(a2_id)).unwrap().title, "a-only"); 1732 + // Stack contains both ids. 1733 + let ids: HashSet<Id> = b.read_stack().unwrap().iter().map(|i| i.id).collect(); 1734 + assert!(ids.contains(&id)); 1735 + assert!(ids.contains(&a2_id)); 1736 + 1737 + // Now both edit the same task body, then B pulls → conflict. 1738 + let mut on_a = a.task(TaskIdentifier::Id(id)).unwrap(); 1739 + on_a.body = "a-edit".into(); 1740 + a.save_task(&on_a).unwrap(); 1741 + a.git_push_refs("origin").unwrap(); 1742 + 1743 + let mut on_b = b.task(TaskIdentifier::Id(id)).unwrap(); 1744 + on_b.body = "b-edit".into(); 1745 + b.save_task(&on_b).unwrap(); 1746 + 1747 + let err = b.git_pull_refs("origin").unwrap_err(); 1748 + let msg = format!("{err}"); 1749 + assert!(msg.contains("conflicts on"), "{msg}"); 1750 + assert!( 1751 + msg.contains(&format!("default/tasks/{}", id.0)), 1752 + "expected the diverged task ref in error: {msg}" 1753 + ); 1754 + // B's local edit is preserved through the failed pull. 1755 + assert_eq!(b.task(TaskIdentifier::Id(id)).unwrap().body, "b-edit"); 1407 1756 } 1408 1757 1409 1758 #[test]