Skip to content

[Fix] Harden git-backed task sync#5

Open
BumpyClock wants to merge 4 commits into
mainfrom
fix/git-sync-bugfix
Open

[Fix] Harden git-backed task sync#5
BumpyClock wants to merge 4 commits into
mainfrom
fix/git-sync-bugfix

Conversation

@BumpyClock

Copy link
Copy Markdown
Owner

Summary

  • Harden git-backed sync against first-publish races, unresolved merge conflicts, and timestamp-ordering drift.
  • Remove dead ID/counter plumbing and consolidate resolver/git helper paths.
  • Keep sync origin-only and clarify main-worktree vs sync-worktree ownership in docs.

Changes

  • Retry when origin/tsq-sync appears between ls-remote and first publish push.
  • Guard tsq sync --no-push from committing unresolved conflict markers.
  • Normalize event timestamps at read boundary and order merge-driver output by (ts, event id).
  • Allow unrelated sync-branch histories during merge, with replay validation still gating output.
  • Simplify random task ID allocation and resolver ambiguity handling.
  • Remove weak/redundant tests; add behavior tests for sync races, conflict handling, timestamp validation, and merge ordering.

Testing

  • cargo test --test sync_branch sync_retries_when_remote_branch_appears_during_publish -- --nocapture
  • cargo test --test sync_branch sync_conflict_returns_structured_error_and_resumes -- --nocapture
  • cargo test --test merge_driver test_merge_driver_concurrent_updates_use_timestamp_order -- --nocapture
  • cargo test --test event_read_validation read_events_rejects_non_rfc3339_timestamp_as_corrupt -- --nocapture
  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --quiet
  • cargo build --release
  • ~/.local/bin/tsq --versiontsq 0.9.0

Review Notes

  • Strict timestamp validation now rejects non-RFC3339 event log lines as EVENTS_CORRUPT; native writers already emit canonical RFC3339.
  • Deferred: unreachable-origin during a second clone's first sync can still create a local orphan branch; existing offline/local-first behavior kept intact for this PR.
  • --allow-unrelated-histories is intentional for independently-created sync branches; merged event streams are still replay-validated.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 865e92ab-17bb-40d8-84a2-31627d2900fa

📥 Commits

Reviewing files that changed from the base of the PR and between 68d6b12 and 2a73bcd.

📒 Files selected for processing (1)
  • tui-opentui/tests/contract.test.ts

Walkthrough

This PR replaces sequential/child task ID allocation with a random canonical tsq-<8> ID allocator, removes child_counters from state, hardens git sync with origin-only push and a bounded fetch/merge/push retry loop, rewrites the merge driver for deterministic causal ordering, tightens event timestamp/corruption validation, refactors ID resolution ambiguity handling, and updates docs and tests.

Changes

Random canonical task IDs, sync hardening, and merge driver

Layer / File(s) Summary
Random ID allocator and state cleanup
src/domain/ids.rs, src/app/service_create_update.rs, src/app/service_utils.rs, src/types.rs, src/domain/state.rs, src/domain/projector_helpers.rs, src/domain/projector_tasks.rs
TaskIdAllocator/make_task_id mint collision-checked tsq-<8 crockford> IDs, replacing RootIdAllocator/next_child_id/unique_root_id; child_counters field and its usages are removed from State and projectors.
Task ID resolution refactor
src/domain/resolve.rs
resolve_task_id uses staged candidate matching (exact alias, id prefix, alias prefix) with shared pick_unique_match/ambiguous helpers for TASK_ID_AMBIGUOUS errors.
Event validation hardening
src/store/events.rs, tests/event_read_validation.rs
Event ts fields must parse as RFC3339 and are normalized; JSONL parsing detects unresolved git conflict markers and returns EVENTS_CORRUPT.
Git helpers for push/merge status
src/store/git.rs
Adds PushOutcome, MergeStatus, merge_in_progress, unmerged_paths, remote_has_branch, fetch_branch, merge_tracking_branch, finalize_merge; merge driver command now uses the current executable path; removes current_upstream_remote/push_current.
Sync worktree retry and locking
src/app/sync.rs, tests/sync_branch.rs
sync_worktree runs under a write lock and delegates to sync_worktree_locked, which finalizes merges, commits locally, gates push on origin, publishes new branches, and retries fetch/merge/push up to 3 times before returning SYNC_PUSH_REJECTED; new integration tests cover convergence, retries, and conflict resolution.
Deterministic merge driver
src/store/merge_driver.rs, tests/merge_driver.rs
Merge logic dedupes by canonical JSON, detects conflicting IDs, builds causal edges, topologically sorts events by (timestamp, id) with cycle fallback, and replays merged output via apply_events, failing with MERGE_REPLAY_FAILED.
Tests and docs for random IDs and sync
tests/common/mod.rs, tests/create_children_batch.rs, tests/readable_identity.rs, tests/verb_first_task_commands.rs, AGENTS-reference.md, README.md, docs/sync.md
Adds is_random_canonical_id test helper, updates ID-related test expectations, and documents random ID formats, two-way sync flow, and commit policy.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant SyncWorktree
  participant Git
  participant Origin

  CLI->>SyncWorktree: sync_worktree(repo_root, push)
  SyncWorktree->>SyncWorktree: acquire write lock
  SyncWorktree->>Git: finalize in-progress merge
  SyncWorktree->>Git: commit local changes
  alt origin not configured
    SyncWorktree-->>CLI: local-only result
  else remote branch missing
    SyncWorktree->>Origin: push_branch_with_status (publish)
  else
    loop up to 3 attempts
      SyncWorktree->>Origin: fetch_branch
      SyncWorktree->>Git: merge_tracking_branch
      alt conflict detected
        SyncWorktree-->>CLI: SYNC_MERGE_CONFLICT
      end
      SyncWorktree->>Origin: push_branch_with_status
      alt push rejected
        SyncWorktree->>SyncWorktree: retry
      else push succeeds
        SyncWorktree-->>CLI: pushed result
      end
    end
    SyncWorktree-->>CLI: SYNC_PUSH_REJECTED (attempts exhausted)
  end
Loading

Possibly related PRs

  • BumpyClock/tasque#1: Both PRs modify task ID allocation, resolution, and duplicate/alias gating in the same creation and ID modules.
  • BumpyClock/tasque#2: Both PRs modify src/app/sync.rs migration/push behavior and merge-driver configuration for the sync branch.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: hardening git-backed task sync.
Description check ✅ Passed The description is directly related to the sync, ID, merge, and docs changes in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/git-sync-bugfix

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/store/merge_driver.rs (1)

205-240: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider atomic write for merged output.

write_events_to_path truncates path via File::create and writes line-by-line before sync_all(). A crash or kill mid-write leaves the merge-driver target file partially written/corrupted rather than either the original or the fully-merged content. Writing to a sibling .tmp file and renaming into place (as done elsewhere in this codebase for durability) would make this crash-safe.

🛡️ Proposed atomic-write fix
 fn write_events_to_path(path: &Path, events: &[EventRecord]) -> Result<(), TsqError> {
-    let mut file = fs::File::create(path).map_err(|e| {
+    let tmp_path = path.with_extension("tmp");
+    let mut file = fs::File::create(&tmp_path).map_err(|e| {
         TsqError::new(
             "MERGE_WRITE_FAILED",
             format!("Failed writing merged events to {}: {}", path.display(), e),
             2,
         )
     })?;

     for record in events {
         let line = serde_json::to_string(record).map_err(|e| {
             TsqError::new(
                 "MERGE_SERIALIZE_FAILED",
                 format!("Failed serializing merged event: {}", e),
                 2,
             )
         })?;
         writeln!(file, "{}", line).map_err(|e| {
             TsqError::new(
                 "MERGE_WRITE_FAILED",
                 format!("Failed writing merged event line: {}", e),
                 2,
             )
         })?;
     }

     file.sync_all().map_err(|e| {
         TsqError::new(
             "MERGE_WRITE_FAILED",
             format!("Failed syncing merged events file: {}", e),
             2,
         )
     })?;

+    fs::rename(&tmp_path, path).map_err(|e| {
+        TsqError::new(
+            "MERGE_WRITE_FAILED",
+            format!("Failed renaming merged events into {}: {}", path.display(), e),
+            2,
+        )
+    })?;
+
     Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/merge_driver.rs` around lines 205 - 240, `write_events_to_path`
currently truncates the target file with `fs::File::create` and writes directly,
which can leave partially written merged output if the process crashes
mid-write. Update this function to write the serialized `EventRecord`s to a
sibling temporary file first, flush/sync it, and then atomically rename it into
place so `merge_driver` only ever exposes the old file or the fully merged file.
Keep the existing `TsqError` handling and make sure the temp-file creation,
write loop, and final rename all surface errors through `write_events_to_path`.
src/store/events.rs (1)

700-733: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Split src/store/events.rs helpers

Conflict-marker detection and CRLF handling are fine, but src/store/events.rs is already 768 LOC, over the 500-line guideline for src/**/*.rs. Move event parsing/validation helpers into a submodule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/events.rs` around lines 700 - 733, Split the event
parsing/validation helpers out of the large events module: keep the
conflict-marker detection and CRLF normalization behavior intact, but move the
parsing logic from the current `src/store/events.rs` flow into a dedicated
submodule so the file stays under the size guideline. Refactor the relevant
helper functions used by the event-reading path (the code around the `lines`
iteration and `TsqError::new` conflict-marker check) into a separate module,
then import and call them from `src/store/events.rs` without changing behavior.

Source: Coding guidelines

src/store/git.rs (1)

160-646: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

File exceeds the 500 LOC guideline.

This file is now roughly ~990 lines (test module ends around line 992), and this PR adds another ~150 lines of git subprocess helpers (push/merge/fetch/branch-status). As per path instructions, src/**/*.rs: "Keep individual files under 500 LOC." Consider splitting sync-related git helpers (push/fetch/merge/status: push_branch_with_status, MergeStatus, merge_in_progress, unmerged_paths, remote_has_branch, fetch_branch, merge_tracking_branch, finalize_merge) into a dedicated module (e.g. src/store/git_sync.rs).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/git.rs` around lines 160 - 646, The file has grown well beyond the
500 LOC guideline due to the new git subprocess helpers. Move the sync-related
functions and types from `src/store/git.rs` into a dedicated module such as
`git_sync.rs`, including `push_current_set_upstream`, `PushOutcome`,
`push_branch_with_status`, `MergeStatus`, `merge_in_progress`, `unmerged_paths`,
`remote_has_branch`, `fetch_branch`, `merge_tracking_branch`, and
`finalize_merge`. Keep `git.rs` focused on shared utilities and update any call
sites/imports to reference the new module so the file stays under the size
limit.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/sync.md`:
- Around line 40-41: The sync docs currently imply that a missing remote branch
causes push to be skipped, but `sync_worktree_locked` still publishes the sync
branch with `push -u` when `origin` exists and only skips pushing when there is
no `origin` remote at all. Update the sentence in the `tsq sync` documentation
to split these cases clearly, using `sync_worktree_locked` and `push -u` as the
reference points so the wording matches the actual behavior.

In `@src/app/sync.rs`:
- Around line 247-351: The sync logic in sync_worktree_locked has pushed the
file over the 500 LOC guideline, so split the new publish/retry orchestration
out into a separate module or helper functions. Keep sync_worktree_locked
focused on high-level flow and move the retry loop, push/merge handling, and
merge_conflict_error-related logic into a dedicated helper with a clear name so
the main file stays under the size limit.
- Line 244: The sync path in sync_worktree_locked currently holds the write lock
while running fetch_branch, merge_tracking_branch, and push_branch_with_status,
and those git calls use Command::output() without any deadline. Add a bounded
timeout or transport-level timeout around those subprocesses so a stalled
network operation cannot keep with_write_lock(repo_root, ...) held indefinitely;
locate the fix in sync_worktree_locked and the helper methods it calls, and
ensure hung git children are killed or fail fast before releasing the lock.
- Around line 294-345: The SYNC_PUSH_REJECTED details currently undercount total
push attempts when the initial publish push in sync.rs is rejected and the later
fetch/merge retry loop also exhausts. Update the attempt tracking in the sync
flow around the remote-branch bootstrap and the retry loop so the final
TsqError::new(...).with_details(...) reports the total number of push attempts
across both phases, not just SYNC_PUSH_MAX_ATTEMPTS from the loop. Use the
existing sync-run branch/push logic and the git::push_branch_with_status outcome
handling to carry a cumulative attempt count into the rejected stderr error
details.

In `@src/domain/ids.rs`:
- Around line 65-75: The legacy ID validator in is_legacy_random_root_id is
duplicating the Crockford alphabet inline, which can drift from the shared
source. Update the validation logic to derive its allowed characters from
CROCKFORD_ALPHABET instead of hardcoded ranges, keeping the check in sync with
any future alphabet changes. Use the existing symbols is_legacy_random_root_id
and CROCKFORD_ALPHABET in src/domain/ids.rs to centralize the character set.

In `@src/store/merge_driver.rs`:
- Around line 39-56: `apply_events(&create_empty_state(), &merged)` in
`merge_driver` replays the entire merged history on every merge, which will
scale poorly as the event log grows. Update the validation path around
`merge_events`/`apply_events` to avoid full from-scratch replay when possible,
such as by validating only the newly merged delta or reusing an already-built
state snapshot instead of always starting from `create_empty_state()`.

---

Outside diff comments:
In `@src/store/events.rs`:
- Around line 700-733: Split the event parsing/validation helpers out of the
large events module: keep the conflict-marker detection and CRLF normalization
behavior intact, but move the parsing logic from the current
`src/store/events.rs` flow into a dedicated submodule so the file stays under
the size guideline. Refactor the relevant helper functions used by the
event-reading path (the code around the `lines` iteration and `TsqError::new`
conflict-marker check) into a separate module, then import and call them from
`src/store/events.rs` without changing behavior.

In `@src/store/git.rs`:
- Around line 160-646: The file has grown well beyond the 500 LOC guideline due
to the new git subprocess helpers. Move the sync-related functions and types
from `src/store/git.rs` into a dedicated module such as `git_sync.rs`, including
`push_current_set_upstream`, `PushOutcome`, `push_branch_with_status`,
`MergeStatus`, `merge_in_progress`, `unmerged_paths`, `remote_has_branch`,
`fetch_branch`, `merge_tracking_branch`, and `finalize_merge`. Keep `git.rs`
focused on shared utilities and update any call sites/imports to reference the
new module so the file stays under the size limit.

In `@src/store/merge_driver.rs`:
- Around line 205-240: `write_events_to_path` currently truncates the target
file with `fs::File::create` and writes directly, which can leave partially
written merged output if the process crashes mid-write. Update this function to
write the serialized `EventRecord`s to a sibling temporary file first,
flush/sync it, and then atomically rename it into place so `merge_driver` only
ever exposes the old file or the fully merged file. Keep the existing `TsqError`
handling and make sure the temp-file creation, write loop, and final rename all
surface errors through `write_events_to_path`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4fd02c8d-1893-48f1-ae78-7f76ba690052

📥 Commits

Reviewing files that changed from the base of the PR and between 4d8e2ef and 68d6b12.

📒 Files selected for processing (22)
  • AGENTS-reference.md
  • README.md
  • docs/sync.md
  • src/app/service_create_update.rs
  • src/app/service_utils.rs
  • src/app/sync.rs
  • src/domain/ids.rs
  • src/domain/projector_helpers.rs
  • src/domain/projector_tasks.rs
  • src/domain/resolve.rs
  • src/domain/state.rs
  • src/store/events.rs
  • src/store/git.rs
  • src/store/merge_driver.rs
  • src/types.rs
  • tests/common/mod.rs
  • tests/create_children_batch.rs
  • tests/event_read_validation.rs
  • tests/merge_driver.rs
  • tests/readable_identity.rs
  • tests/sync_branch.rs
  • tests/verb_first_task_commands.rs
💤 Files with no reviewable changes (4)
  • src/domain/state.rs
  • src/domain/projector_helpers.rs
  • src/types.rs
  • src/app/service_utils.rs

Comment thread docs/sync.md
Comment on lines +40 to +41
When no remote/remote branch exists, `tsq sync` commits locally and reports that
push was skipped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc inaccuracy: "no remote branch" does not result in a skipped push.

Per src/app/sync.rs::sync_worktree_locked, when origin exists but doesn't yet have the sync branch, tsq sync actively publishes it via push -u (and only falls back to the fetch/merge/retry loop if that publish push is rejected) — it does not skip the push. Only the "no origin remote at all" case skips push. Suggest splitting this sentence to avoid implying the sync branch is left unpublished when a remote exists.

📝 Proposed doc fix
-When no remote/remote branch exists, `tsq sync` commits locally and reports that
-push was skipped.
+When no `origin` remote exists, `tsq sync` commits locally and reports that push
+was skipped. When `origin` exists but has no sync branch yet, `tsq sync` publishes
+it and sets upstream automatically.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
When no remote/remote branch exists, `tsq sync` commits locally and reports that
push was skipped.
When no `origin` remote exists, `tsq sync` commits locally and reports that push
was skipped. When `origin` exists but has no sync branch yet, `tsq sync` publishes
it and sets upstream automatically.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/sync.md` around lines 40 - 41, The sync docs currently imply that a
missing remote branch causes push to be skipped, but `sync_worktree_locked`
still publishes the sync branch with `push -u` when `origin` exists and only
skips pushing when there is no `origin` remote at all. Update the sentence in
the `tsq sync` documentation to split these cases clearly, using
`sync_worktree_locked` and `push -u` as the reference points so the wording
matches the actual behavior.

Comment thread src/app/sync.rs

// Hold the Tasque write lock around the whole commit/fetch/merge/push
// sequence so concurrent writers can't append events mid-sync.
with_write_lock(repo_root, || sync_worktree_locked(path, &branch, push))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Sync holds the write lock across unbounded network calls (fetch/merge/push) with no timeout.

sync_worktree now wraps the whole commit/fetch/merge/push retry sequence in with_write_lock (line 244), and the git subprocesses invoked by fetch_branch/merge_tracking_branch/push_branch_with_status run via Command::output() with no timeout. If a network call hangs (unreachable remote, slow transport, auth prompt), the write lock is held indefinitely, and all other concurrent writers (create/edit/etc.) will fail with lock/concurrency errors for as long as the hang lasts, since they only get a short lock-acquire retry.

Consider adding a bounded timeout around the git subprocess calls used during sync (e.g. http.lowSpeedLimit/http.lowSpeedTime for HTTP transports, GIT_SSH_COMMAND with ConnectTimeout for SSH, or a wrapper that kills the child process after a deadline) so a stuck sync can't indefinitely block the rest of the tool.

Also applies to: 312-348

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/sync.rs` at line 244, The sync path in sync_worktree_locked currently
holds the write lock while running fetch_branch, merge_tracking_branch, and
push_branch_with_status, and those git calls use Command::output() without any
deadline. Add a bounded timeout or transport-level timeout around those
subprocesses so a stalled network operation cannot keep
with_write_lock(repo_root, ...) held indefinitely; locate the fix in
sync_worktree_locked and the helper methods it calls, and ensure hung git
children are killed or fail fast before releasing the lock.

Comment thread src/app/sync.rs
Comment on lines +247 to +351
fn sync_worktree_locked(path: &Path, branch: &str, push: bool) -> Result<SyncRunResult, TsqError> {
let worktree_path = path.to_string_lossy().to_string();

// Finalize any merge left in progress by a prior conflicted sync. If
// conflicts remain unresolved, re-emit the structured conflict error before
// any local commit path can stage conflict markers.
let mut committed = false;
if git::merge_in_progress(path)? {
let unmerged = git::unmerged_paths(path)?;
if !unmerged.is_empty() {
return Err(merge_conflict_error(branch, &worktree_path, unmerged));
}
git::finalize_merge(path)?;
committed = true;
}

// `--no-push`: purely local commit, no network.
if !push {
if git::commit_worktree(path, SYNC_COMMIT_MESSAGE)? {
committed = true;
}
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: false,
has_upstream: git::has_upstream(path)?,
});
}

// Commit local changes first.
if git::commit_worktree(path, SYNC_COMMIT_MESSAGE)? {
committed = true;
}

// Origin-only sync: no remote configured means local commit only.
if !git::has_remote(path, "origin")? {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: false,
has_upstream: false,
});
}
let remote = "origin";

// No remote branch yet: publish and set upstream. If another clone creates
// it after our check but before our push, fall through to the same
// fetch/merge/retry loop used for established branches.
if !git::remote_has_branch(path, remote, branch)? {
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(_) => {}
}
}

// Remote branch exists: fetch + merge before pushing, with bounded retry on
// non-fast-forward rejection (remote advanced during our merge/push).
for attempt in 0..SYNC_PUSH_MAX_ATTEMPTS {
git::fetch_branch(path, remote, branch)?;
match git::merge_tracking_branch(path, remote, branch)? {
git::MergeStatus::Clean => {}
git::MergeStatus::Conflict(paths) => {
return Err(merge_conflict_error(branch, &worktree_path, paths));
}
}
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(stderr) => {
if attempt + 1 >= SYNC_PUSH_MAX_ATTEMPTS {
return Err(TsqError::new(
"SYNC_PUSH_REJECTED",
"remote rejected push after retrying fetch/merge",
2,
)
.with_details(serde_json::json!({
"branch": branch,
"remote": remote,
"attempts": SYNC_PUSH_MAX_ATTEMPTS,
"stderr": crate::cli::render::sanitize_inline(&stderr),
})));
}
}
}
}

unreachable!("push retry loop returns on success, conflict, or exhausted attempts")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

File likely exceeds the 500 LOC guideline.

Shown content already spans to line ~594, and this PR adds ~130 more lines (sync_worktree_locked, retry loop, merge_conflict_error). As per path instructions, src/**/*.rs: "Keep individual files under 500 LOC." Consider splitting the retry/publish orchestration in sync_worktree_locked into its own module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/sync.rs` around lines 247 - 351, The sync logic in
sync_worktree_locked has pushed the file over the 500 LOC guideline, so split
the new publish/retry orchestration out into a separate module or helper
functions. Keep sync_worktree_locked focused on high-level flow and move the
retry loop, push/merge handling, and merge_conflict_error-related logic into a
dedicated helper with a clear name so the main file stays under the size limit.

Source: Path instructions

Comment thread src/app/sync.rs
Comment on lines +294 to +345
// No remote branch yet: publish and set upstream. If another clone creates
// it after our check but before our push, fall through to the same
// fetch/merge/retry loop used for established branches.
if !git::remote_has_branch(path, remote, branch)? {
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(_) => {}
}
}

// Remote branch exists: fetch + merge before pushing, with bounded retry on
// non-fast-forward rejection (remote advanced during our merge/push).
for attempt in 0..SYNC_PUSH_MAX_ATTEMPTS {
git::fetch_branch(path, remote, branch)?;
match git::merge_tracking_branch(path, remote, branch)? {
git::MergeStatus::Clean => {}
git::MergeStatus::Conflict(paths) => {
return Err(merge_conflict_error(branch, &worktree_path, paths));
}
}
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(stderr) => {
if attempt + 1 >= SYNC_PUSH_MAX_ATTEMPTS {
return Err(TsqError::new(
"SYNC_PUSH_REJECTED",
"remote rejected push after retrying fetch/merge",
2,
)
.with_details(serde_json::json!({
"branch": branch,
"remote": remote,
"attempts": SYNC_PUSH_MAX_ATTEMPTS,
"stderr": crate::cli::render::sanitize_inline(&stderr),
})));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

SYNC_PUSH_REJECTED.details.attempts undercounts actual push attempts.

When the "no remote branch yet" publish push (lines 297-310) is itself rejected (branch appeared concurrently) and the subsequent retry loop is then exhausted, the reported attempts field is SYNC_PUSH_MAX_ATTEMPTS (3), omitting the earlier publish attempt. The error message reads "remote rejected push after retrying fetch/merge," which is accurate for the loop but the attempts count under-reports the real number of push attempts made, which could mislead debugging.

♻️ Proposed fix: track total attempts across publish + retry loop
-    if !git::remote_has_branch(path, remote, branch)? {
-        match git::push_branch_with_status(path, remote, branch)? {
+    let mut total_attempts = 0usize;
+    if !git::remote_has_branch(path, remote, branch)? {
+        total_attempts += 1;
+        match git::push_branch_with_status(path, remote, branch)? {
             git::PushOutcome::Ok => {
                 return Ok(SyncRunResult {
                     branch: branch.to_string(),
                     worktree_path,
                     committed,
                     pushed: true,
                     has_upstream: true,
                 });
             }
             git::PushOutcome::Rejected(_) => {}
         }
     }

     for attempt in 0..SYNC_PUSH_MAX_ATTEMPTS {
+        total_attempts += 1;
         git::fetch_branch(path, remote, branch)?;
         ...
             git::PushOutcome::Rejected(stderr) => {
                 if attempt + 1 >= SYNC_PUSH_MAX_ATTEMPTS {
                     return Err(TsqError::new(...)
                         .with_details(serde_json::json!({
                             "branch": branch,
                             "remote": remote,
-                            "attempts": SYNC_PUSH_MAX_ATTEMPTS,
+                            "attempts": total_attempts,
                             "stderr": ...,
                         })));
                 }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// No remote branch yet: publish and set upstream. If another clone creates
// it after our check but before our push, fall through to the same
// fetch/merge/retry loop used for established branches.
if !git::remote_has_branch(path, remote, branch)? {
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(_) => {}
}
}
// Remote branch exists: fetch + merge before pushing, with bounded retry on
// non-fast-forward rejection (remote advanced during our merge/push).
for attempt in 0..SYNC_PUSH_MAX_ATTEMPTS {
git::fetch_branch(path, remote, branch)?;
match git::merge_tracking_branch(path, remote, branch)? {
git::MergeStatus::Clean => {}
git::MergeStatus::Conflict(paths) => {
return Err(merge_conflict_error(branch, &worktree_path, paths));
}
}
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(stderr) => {
if attempt + 1 >= SYNC_PUSH_MAX_ATTEMPTS {
return Err(TsqError::new(
"SYNC_PUSH_REJECTED",
"remote rejected push after retrying fetch/merge",
2,
)
.with_details(serde_json::json!({
"branch": branch,
"remote": remote,
"attempts": SYNC_PUSH_MAX_ATTEMPTS,
"stderr": crate::cli::render::sanitize_inline(&stderr),
})));
}
// No remote branch yet: publish and set upstream. If another clone creates
// it after our check but before our push, fall through to the same
// fetch/merge/retry loop used for established branches.
let mut total_attempts = 0usize;
if !git::remote_has_branch(path, remote, branch)? {
total_attempts += 1;
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(_) => {}
}
}
// Remote branch exists: fetch + merge before pushing, with bounded retry on
// non-fast-forward rejection (remote advanced during our merge/push).
for attempt in 0..SYNC_PUSH_MAX_ATTEMPTS {
total_attempts += 1;
git::fetch_branch(path, remote, branch)?;
match git::merge_tracking_branch(path, remote, branch)? {
git::MergeStatus::Clean => {}
git::MergeStatus::Conflict(paths) => {
return Err(merge_conflict_error(branch, &worktree_path, paths));
}
}
match git::push_branch_with_status(path, remote, branch)? {
git::PushOutcome::Ok => {
return Ok(SyncRunResult {
branch: branch.to_string(),
worktree_path,
committed,
pushed: true,
has_upstream: true,
});
}
git::PushOutcome::Rejected(stderr) => {
if attempt + 1 >= SYNC_PUSH_MAX_ATTEMPTS {
return Err(TsqError::new(
"SYNC_PUSH_REJECTED",
"remote rejected push after retrying fetch/merge",
2,
)
.with_details(serde_json::json!({
"branch": branch,
"remote": remote,
"attempts": total_attempts,
"stderr": crate::cli::render::sanitize_inline(&stderr),
})));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/sync.rs` around lines 294 - 345, The SYNC_PUSH_REJECTED details
currently undercount total push attempts when the initial publish push in
sync.rs is rejected and the later fetch/merge retry loop also exhausts. Update
the attempt tracking in the sync flow around the remote-branch bootstrap and the
retry loop so the final TsqError::new(...).with_details(...) reports the total
number of push attempts across both phases, not just SYNC_PUSH_MAX_ATTEMPTS from
the loop. Use the existing sync-run branch/push logic and the
git::push_branch_with_status outcome handling to carry a cumulative attempt
count into the rejected stderr error details.

Comment thread src/domain/ids.rs
Comment on lines +65 to +75
fn is_legacy_random_root_id(raw: &str) -> bool {
let Some(rest) = raw.strip_prefix("tsq-") else {
return false;
};
rest.len() == 8
&& rest
.chars()
.all(|ch| matches!(ch, '0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'))
}

pub fn next_child_id(state: &State, parent_id: &str) -> String {
let max_child = state.child_counters.get(parent_id).copied().unwrap_or(0);
format!("{}.{}", parent_id, max_child + 1)
}

fn sequential_number(raw: &str) -> Option<u64> {
if !is_sequential_root_id(raw) {
return None;
}
let suffix = raw.strip_prefix("tsq-")?;
suffix.parse::<u64>().ok()
&& rest.chars().all(|ch| {
matches!(
ch,
'0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'
)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the legacy-random char set from CROCKFORD_ALPHABET to avoid drift.

is_legacy_random_root_id hardcodes the Crockford set as inline char ranges ('0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'). It currently matches CROCKFORD_ALPHABET exactly, but minting and validation now maintain two independent copies of the same alphabet; a future edit to one can silently diverge and accept/reject IDs inconsistently. Consider validating against the shared constant.

♻️ Reuse the shared alphabet
 fn is_legacy_random_root_id(raw: &str) -> bool {
     let Some(rest) = raw.strip_prefix("tsq-") else {
         return false;
     };
-    rest.len() == 8
-        && rest.chars().all(|ch| {
-            matches!(
-                ch,
-                '0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'
-            )
-        })
+    rest.len() == RANDOM_ID_LEN
+        && rest
+            .bytes()
+            .all(|b| CROCKFORD_ALPHABET.contains(&b))
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn is_legacy_random_root_id(raw: &str) -> bool {
let Some(rest) = raw.strip_prefix("tsq-") else {
return false;
};
rest.len() == 8
&& rest
.chars()
.all(|ch| matches!(ch, '0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'))
}
pub fn next_child_id(state: &State, parent_id: &str) -> String {
let max_child = state.child_counters.get(parent_id).copied().unwrap_or(0);
format!("{}.{}", parent_id, max_child + 1)
}
fn sequential_number(raw: &str) -> Option<u64> {
if !is_sequential_root_id(raw) {
return None;
}
let suffix = raw.strip_prefix("tsq-")?;
suffix.parse::<u64>().ok()
&& rest.chars().all(|ch| {
matches!(
ch,
'0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z'
)
})
fn is_legacy_random_root_id(raw: &str) -> bool {
let Some(rest) = raw.strip_prefix("tsq-") else {
return false;
};
rest.len() == RANDOM_ID_LEN
&& rest
.bytes()
.all(|b| CROCKFORD_ALPHABET.contains(&b))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/ids.rs` around lines 65 - 75, The legacy ID validator in
is_legacy_random_root_id is duplicating the Crockford alphabet inline, which can
drift from the shared source. Update the validation logic to derive its allowed
characters from CROCKFORD_ALPHABET instead of hardcoded ranges, keeping the
check in sync with any future alphabet changes. Use the existing symbols
is_legacy_random_root_id and CROCKFORD_ALPHABET in src/domain/ids.rs to
centralize the character set.

Comment thread src/store/merge_driver.rs
Comment on lines +39 to +56
fn order_key(id: &str, records: &HashMap<String, EventRecord>) -> Reverse<(String, String)> {
let record = records.get(id).expect("id present in map");
Reverse((record.ts.clone(), id.to_string()))
}

/// Merge three versions of an events.jsonl file (ancestor, ours, theirs).
///
/// Algorithm:
/// 1. Read all three files
/// 2. Build a stable union keyed by event ID
/// 2. Build a union keyed by event ID (prefers `id`, falls back to `event_id`)
/// 3. Detect conflicts: same ID but different payload across files
/// 4. Deduplicate identical events
/// 5. Preserve source order: ancestor, then ours-only, then theirs-only
/// 5. Produce a deterministic order via a topological sort that:
/// - preserves each source's internal causal order (event[i] before event[i+1])
/// - breaks ties by smallest event ID (min-heap)
/// This makes the merged output byte-identical for `A<-B` and `B<-A` when
/// both sides add independent events, while still keeping causal chains
/// (e.g. create-before-update) intact even when IDs sort backwards.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial

LGTM! The causal-edge + Kahn topological sort with (ts, id) tie-breaking, cycle fallback, and pre-write replay validation are correctly implemented and match the new test expectations (causal order, cross-timezone tie-breaks, commutativity, cycle fallback, replay-failure rollback).

One scalability note: apply_events(&create_empty_state(), &merged) fully replays the entire merged event stream from scratch on every merge invocation. For large event logs this could become a noticeable cost on every git merge/sync cycle; worth keeping an eye on as event history grows.

Also applies to: 68-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/merge_driver.rs` around lines 39 - 56,
`apply_events(&create_empty_state(), &merged)` in `merge_driver` replays the
entire merged history on every merge, which will scale poorly as the event log
grows. Update the validation path around `merge_events`/`apply_events` to avoid
full from-scratch replay when possible, such as by validating only the newly
merged delta or reusing an already-built state snapshot instead of always
starting from `create_empty_state()`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant