Skip to content

fix(session): name archive repo directories with ~XX so GitHub and GitLab can open them - #1576

Merged
SaulMoro merged 10 commits into
Tencent:mainfrom
hz89Dev:fix/session-repo-dir-escape
Oct 30, 2026
Merged

SaulMoro merged 10 commits into
Tencent:mainfrom
hz89Dev:fix/session-repo-dir-escape

Conversation

@hz89Dev

@hz89Dev hz89Dev commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

teamai session push archives a repo's sessions under sessions/repos/<escaped remote>/, and the escape was %XX. For a repo at github.com/acme/app that gives sessions/repos/github.com%2Facme%2Fapp/. GitHub and GitLab URL-decode a path once, so %2F becomes / again, and the directory returns 404 in the web UI and the contents API (gh api .../contents/...). Git itself handles it, which is why teamai never noticed.

The escape is now ~XX per UTF-8 byte. ~ is unreserved in URLs, so nothing decodes it, and it is valid on Windows. An archive still under the %XX name keeps being read there, and the next push moves it:

 sessions/repos/
-└── github.com%2Facme%2Fapp/     # 404 on GitHub/GitLab
+└── github.com~2Facme~2Fapp/
     ├── _index.json
     └── alice/...
saveSession(identity)
  moveRepoDirToCurrentName                       # new
    local change to a committed file under either name, staged or not -> leave both
    no ~XX dir   -> rename %XX to ~XX
    both exist   -> an older teamai pushed under %XX after the move: foldPercentRepoDir
                    per committed session file (platform + author + source session id, from the repaired ~XX index):
                      held in ~XX -> the later push wins
                      not held    -> moves under a free name (the file name does not identify a session)
                    rebuild the ~XX index
    commit the move on its own: "sessions: move <%XX> to <~XX>"
    commit fails -> undo the move on disk and in the index; the archive stays under %XX
  ...write and commit the session as before

Because the move is its own commit, and is undone when that commit fails:

  • a failed commit never leaves a half-done move to recover;
  • local edits and deletions are never published;
  • files the archive never committed are never added.

When the session's own commit fails afterwards, it stays staged as before, and a later push on a clean archive moves the directory.

session pull --all no longer renames %XX directories itself, because that rename would stay uncommitted. It still moves legacy _-folded names as before.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Test Plan

Tested commit ba898188 on macOS, Node v24.21.0.

  • npx tsc --noEmit passes
  • npm run lint passes
  • npx vitest run: all pass except request-deadline tests in gitlab-mr-timeout, gitlab-mr-create-timeout and gitlab-org-timeout, which do not import session-flow and fail a different subset each run.
  • Added/updated tests for the change (session-sync.test.ts), each seen failing first:
    • leaves no % in the name was red with github.com%2Forg%2Frepo
    • moves a %-escaped repository directory to its current name in the next push was red: % paths stayed on the remote, and an untracked file in the directory would be published
    • undoes a move whose commit is rejected, and moves the archive on a later push, leaving unrelated files out (HEAD and disk unchanged after the rejected commit)
    • folds sessions an older CLI pushed under the %-escaped name after the move into the current directory, once each was red: those sessions stayed hidden, a session re-pushed under a new title was listed twice, and a distinct session with the same file name was lost
    • undoes a fold whose commit is rejected while a push updates a held session, and folds on a later push
    • restores the percent-escaped directory when a rejected fold had only untracked files under the current name was red: the session was gone from disk
    • folds a session held under the current name once, even when that directory has no index was red: an _1 duplicate
    • keeps a session under the percent-escaped name when the current name holds it only as untracked files was red: the session left HEAD
    • leaves sessions an older CLI pushed under the percent-escaped name in place while the current directory holds local edits was red: the local edit was overwritten
    • publishes none of <change> under a percent-escaped repository directory, for an unstaged or staged edit, an unstaged or staged deletion, and the whole directory deleted. All but the unstaged deletion were red at some point.
    • publishes none of the edits staged under both repository directory names, publishes neither a staged deletion under the percent-escaped name nor a staged edit under the current one, publishes no edit made between a rejected move and the next push
    • leaves a %-escaped repository directory for the next push to move when rebuilding every index was red: the checkout had an uncommitted D sessions/repos/github.com%2F...

Real CLI (npm run build, then node dist/index.js session push --source claude-code --repo-root <archive> --scrub -y --limit 1), with a bare remote and a workspace whose origin is https://github.com/acme/app.git.

An archive under the old name moves on the next push:

first push dir: github.com~2Facme~2Fapp
remote before: sessions/repos/github.com%2Facme%2Fapp          # archive rewritten to the old name
  ✓ Pushed 1 session(s) from claude-code
remote after:
sessions/repos/github.com~2Facme~2Fapp/_index.json
sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-1_20261021.{jsonl,meta.json}
sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-2_20261022.{jsonl,meta.json}
archive status: []
remote log:
  sync: push 1 session(s) from claude-code
  sessions: move github.com%2Facme%2Fapp to github.com~2Facme~2Fapp
  previous layout
  sync: push 1 session(s) from claude-code
session list: 2 session(s)

A mixed-version team, with the published teamai-cli@0.28.1 as the older CLI:

[new 1]   ✓ Pushed 1 session(s) from claude-code
[old 3]   ✓ Pushed 1 session(s) from claude-code
remote after the old CLI:
    sessions/repos/github.com%2Facme%2Fapp/_index.json
    sessions/repos/github.com%2Facme%2Fapp/unknown/claude-code_archive-session-3_20261023.{jsonl,meta.json}
    sessions/repos/github.com~2Facme~2Fapp/_index.json
    sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-1_20261021.{jsonl,meta.json}
[new 4]   ✓ Pushed 1 session(s) from claude-code
remote after the new CLI:
    sessions/repos/github.com~2Facme~2Fapp/_index.json
    sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-1_20261021.{jsonl,meta.json}
    sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-3_20261023.{jsonl,meta.json}
    sessions/repos/github.com~2Facme~2Fapp/unknown/claude-code_archive-session-4_20261024.{jsonl,meta.json}
archive status: []
session list: 3 session(s)

Only the claude-code source and a plain git remote were run locally; the other agents and providers are left to CI. I checked that GitHub decodes %2F (the contents API returns 404 for the %2F path and the listing for %252F), but did not push a ~2F directory to a live GitHub or GitLab remote.

Related Issues

Notes for Reviewers

  • Sessions an older teamai pushes under %XX stay hidden from newer clients until the next newer-teamai push for that repo folds them in. The CHANGELOG line asks teams to upgrade together.
  • decodeRepoIdentity reads both forms. The %XX form left ~ literal, so a name that contains % is decoded as-is.

…tLab can open them

Repo directories under sessions/repos/ were escaped as %XX
(github.com%2Forg%2Frepo). GitHub and GitLab URL-decode a path once,
so %2F turned back into a separator and the directory 404ed in the
web UI and the contents API.

The escape is now ~XX per UTF-8 byte. An archive still under its %XX
name is read there and moved to the new name by the next save, which
records each file HEAD holds under the old name so the commit stages
the rename, and a retry after a failed commit finds it again.
`session pull --all` leaves %XX directories for that save to move,
since its own rename would stay uncommitted.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Preserve unrelated tracked changes during migration — src/session-flow/sync.ts:540. Every file under the old directory in HEAD enters writtenPaths, but staging uses its current on-disk content. If a user locally edits or deletes archived session A, then pushes unrelated session B, migration also publishes A’s edit or deletion. This regresses the existing restriction to files written by this run. Preserve HEAD content for the rename while leaving unrelated local changes uncommitted, or refuse migration when those files are dirty.

The PR description documents sufficient testing: a build followed by a representative real-CLI push verifies migration against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in c4a6518. A %XX directory with uncommitted changes to tracked files is no longer moved: git status --porcelain --untracked-files=no on it must be empty first, otherwise it stays under %XX and the save writes there as before. Nothing from HEAD is recorded while the old directory is still on disk. New test leaves a %-escaped repository directory in place while it holds uncommitted edits, and publishes none of them: it edits an archived jsonl, pushes another session, and asserts HEAD keeps the original content and the edit stays unstaged. It was red before the fix.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This was generated by AI during triage.

Thanks for the quick fix in c4a6518. I reproduced the earlier P1 at d007db3, where pushing a new session published a local edit to an archived session and the deletion of another. At c4a6518 the move is skipped while %XX has uncommitted changes: the commit holds only the index and the new session, and the next clean push moves the directory to ~XX.

One issue remains: teammates on an older CLI can push into %XX after the move.

[P1 blocking] After the move, sessions a teammate pushes with an older CLI stay hidden, and pushing them again creates duplicates. repoDir prefers ~XX whenever it exists (src/session/sync.ts:517-519). moveRepoDirToCurrentName returns early when ~XX exists (:542, :547), so it never moves files out of a %XX directory created later. I reproduced this with a local bare remote, an archive clone per member, and the merge-base and c4a6518 builds:

  1. Alice (new CLI) pushes S2. The remote now holds only github.com~2Facme~2Fapp/.
  2. Carol (old CLI) runs session pull, then pushes S3. The push succeeds and S3 goes into a new github.com%2Facme%2Fapp/ with its own _index.json.
  3. Alice's session list and list --all show S1 and S2, and session search "Session 3" --all does not return S3. Only pull --all warns: "both hold ... leaving github.com%2Facme%2Fapp in place".
  4. Carol upgrades and pushes S5. S3 is still hidden: her own session list does not show it. No later push moves it.
  5. Bob (old CLI) pushes S6 into %2F, then upgrades and pushes the same workspace. Dedup reads only the ~XX index, so S6 lands a second time under ~2F. The remote now holds S6 in both directories.

Teams often run mixed CLI versions during a rollout, so this will happen. Suggested fix, inside the function this PR adds: when both directories exist and %XX is clean, git mv each committed file under %XX whose ~XX path does not exist yet, then rebuild the ~XX index. With the dirty check from c4a6518, this only moves committed content. A test that pushes into %XX after the move, then pushes with the new CLI and expects one directory and a listed session, covers both cases.

[P3 nit] Add a CHANGELOG line. The archive layout on the remote changes, so a line under [Unreleased] telling teams to upgrade together would help, as recent fixes do (for example #1554).

No doc changes are needed. The encoding is reversible (a literal ~ encodes as ~7E), and the names are valid on Windows.

… after the move

A teammate still on an older teamai writes under the %XX name after the
archive moved to ~XX, so their sessions stayed hidden and pushing them
again with a newer teamai stored them twice.

The next save now folds that directory into the current one session by
session, keyed by platform, author and source session id: a session the
current directory holds keeps the later push, and any other one moves
under a free name, since the file name does not identify a session. The
fold waits while either directory holds local changes, staged or not,
so it never publishes or overwrites them.
@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed in cab5b6f.

[P1] Older CLI pushing into %XX after the move: when both directories exist, the next save folds %XX into ~XX session by session, keyed by platform, author and source session id:

  • a session ~XX already holds keeps the later pushedAt, so your step 5 stores S6 once;
  • any other session moves under a free name, because two distinct sessions can share a file name (same title and date).

The ~XX index is then rebuilt. The fold waits while either directory has local changes, staged or not, so it never publishes or overwrites them.

Test: folds sessions an older CLI pushed under the %-escaped name after the move into the current directory, once each. It covers a hidden session, the same session re-pushed under a changed title, and a distinct session with a colliding file name, and was red before the change. Real CLI with the published 0.28.1 as the older client: new pushes S1, 0.28.1 pushes S3 under %2F, new pushes S4. The remote then holds only ~2F with S1, S3 and S4, and session list shows 3. The full record is in the PR description.

As in your scenario, S3 is hidden until that next newer-CLI push.

[P3] CHANGELOG: added under [Unreleased] / Changed, with the advice to upgrade together.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Preserve unrelated local deletions during migration — src/session-flow/sync.ts:539. Staged deletions are excluded from the dirty check, and deleting the entire %XX directory bypasses that check altogether. Pushing unrelated session B then adds those deleted HEAD paths to writtenPaths and commits session A’s local deletion. The earlier finding’s edit case is resolved, but its deletion case remains.

  • [P1 blocking] Recover collision-renamed files when retrying a fold — src/session-flow/sync.ts:555. If both directories contain different sessions with the same filename, folding moves the old session to a conflict-resolved name. After a rejected commit, retry reconstructs destinations using only the original filename, omitting the renamed files from the scoped commit. The old session’s deletion is committed without its replacement, so it disappears from the remote. Retry must recover the actual fold destinations.

The description documents sufficient testing: a build followed by a representative real-CLI push against a bare remote. The older tested commit is a note, not a blocker; additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Re-checked at cab5b6f with the real CLI (local bare remote, the merge-base build as the older CLI).

Resolved:

  • Older-CLI sessions under %XX after the move: the next push folds them in. The remote ends with only ~2F, and session list shows every session. A session pushed by the old CLI and then re-pushed after upgrading is stored once.
  • CHANGELOG line: added.

The CI review's two findings both reproduce, so this still needs changes before merge:

[P1 blocking] A local deletion under %XX is published by an unrelated push. The dirty check in moveRepoDirToCurrentName (src/session-flow/sync.ts:538-560) skips staged deletions (--diff-filter=d) and is skipped entirely when %XX is gone, and then every HEAD path under %XX goes into the commit.

  • git rm of session S2 under %2F, then push S3: S2 is deleted on the remote. At c4a6518 the same run left %2F in place and kept the deletion local, so this case is new in cab5b6f.
  • The same on the fold path (S4 under %2F after the move, git rm S4, push S5): S4 is deleted on the remote. That contradicts "the fold waits while either directory has local changes, staged or not".
  • rm -rf of the whole %2F directory, then push S3: every session that was in it is deleted on the remote. This was already the case at c4a6518; I missed it in the last round.

[P1 blocking] A retry after a rejected commit drops a session the fold renamed. When a %XX session's file name is taken under ~XX, the fold moves it to <name>_1 (:596-614), but only that run knows it. If the commit is rejected (a pre-commit hook, here), the retry maps each %XX path to ~XX/<same name> (:555-559), so <name>_1 stays out of the pathspec. The retry commits the deletion of the %XX file without its replacement: the session's .jsonl is in no commit, and the remote _index.json lists a file that does not exist.

One change can fix both: on a run or a retry, count a HEAD path under %XX as removed by the move only when the same blob exists under ~XX (compare git ls-tree HEAD blob ids with git hash-object of the ~XX files), and find its destination by blob, not by name. Treat any other missing path as a local change that blocks the move. A fold that drops an older duplicate can stage that removal itself (git rm --cached), so the retry sees it as the fold's. Worth covering in tests: a staged deletion, a deleted whole %XX directory, and a name collision with a rejected first commit.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in 2136038.

Local deletions: any staged or unstaged change under %XX, deletions included, now leaves the directory alone. That covers deleting the whole directory: the check reads the git index instead of skipping when the directory is gone. Test: publishes none of <change> under a percent-escaped repository directory, which runs an unstaged edit, a staged edit, an unstaged deletion, a staged deletion, and the whole directory deleted. The staged-deletion and whole-directory cases were red before the fix.

Retry after a fold that renamed a session: a failed commit leaves the move staged under both names. When both have staged paths, the retry records exactly those paths, including the conflict-resolved name, and does not move again. Test: commits a fold that renamed a session on the retry after a rejected commit, which was red before: the renamed session was missing from HEAD.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Do not treat arbitrary staged changes as a migration retry — src/session-flow/sync.ts:548. When both %XX and ~XX directories exist, staging an unrelated session edit in each makes this branch bypass both dirty checks and add those paths to writtenPaths. Pushing a third session then commits both local edits, even though no migration failed. Verify that the staged changes represent a migration before accepting them as retry paths.

The previously reported deletion and collision-renamed retry scenarios are resolved in the current diff.

The description documents sufficient testing: a build followed by representative real-CLI pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Re-checked at 2136038 with the real CLI (local bare remote, the merge-base build as the older CLI).

Resolved:

  • Deletions under %XX, whether a staged git rm before the move, the same during the fold, or rm -rf of the whole directory, now stay local, and the remote keeps those sessions.
  • A retry after a rejected commit now publishes the session the fold renamed to _1, session list shows all of them, and the checkout is clean.
  • Older-CLI sessions fold in, and a re-pushed one is stored once (same as at cab5b6f).

[P1 blocking] Staged changes in both directories are taken as a leftover move and published. I agree with the CI review and reproduced it. When both %2F and ~2F exist and each has a staged change, the retry branch (src/session-flow/sync.ts:546-551) skips both dirty checks and puts everything staged into the next push's commit:

Before pushing S5 Remote after the push
Staged edits under both directories Both edits published, and the fold is skipped
git rm of S3 under %2F, staged edit under ~2F S3 deleted (the %2F index still lists it), and the edit published
Staged edit under %XX only Nothing published (correct)
Staged edit under ~XX only Nothing published (correct)

The second row is the deletion case from last round, coming back through the retry branch.

Every variant over the last three rounds came from inferring "this is my own unfinished move" from the index or HEAD. A narrower design closes the whole class:

  • Move or fold only when git status --porcelain --untracked-files=no is empty for both directories.
  • If the commit fails, undo the move in the same run. That run knows exactly which paths it moved.
  • Drop the retry branch.

Then nothing a member staged or deleted can be mistaken for the move. The alternative, keeping the retry but accepting only exact renames (same blob under both names), needs special cases for fold drops and the failed push's own new session, so it is easier to get wrong.

[P3 nit] The dirty-directory test dropped its listSessions assertion for all five cases. Only the deleted-directory case needed that.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in ac81a39. Staged paths now count as a move to retry only when everything staged under %XX is a removal, which is all a failed move leaves there, and ~XX has staged changes. A staged edit under %XX falls through to the local-changes check, and the directory is left alone. Test: publishes none of the edits staged under both repository directory names, which stages an edit in each directory and pushes another session. It was red before the fix: both edits were committed.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Reject staged deletions plus unrelated edits as a migration retry — src/session-flow/sync.ts:549. With both directories present, stage a session deletion under %XX and an unrelated session edit under ~XX, then push a third session. isStagedMove accepts this state and commits both unrelated changes, although no migration failed. Verify the staged paths represent an actual migration before bypassing the dirty checks.

  • [P1 blocking] Preserve local changes made after a rejected migration commit — src/session-flow/sync.ts:551. After a migration commit is rejected, locally edit or delete migrated session A, then retry pushing unrelated session B. The retry recovers A into writtenPaths without checking unstaged changes; gitCommit then publishes A’s current edit or deletion. Refuse recovery when those paths have additional worktree changes, or preserve the migration snapshot when committing.

The earlier single-directory edit/deletion cases, collision-renamed retry case, and staged-edit-in-both-directories scenario are resolved.

The description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

…old the moved content and nothing changed since
@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in f8eb1cd. A retry is now accepted only when the staged state is the move's own:

  • only removals under %XX;
  • under ~XX, only added files, the rebuilt _index.json, or a blob HEAD held under %XX, compared by git diff --cached --raw against git ls-tree HEAD;
  • no unstaged change under either name.

Anything else falls through to the local-changes check, and the directory is left alone.

Tests, both red before the fix:

  • publishes neither a staged deletion under the percent-escaped name nor a staged edit under the current one
  • publishes no edit made to a moved session between a rejected commit and its retry

Both scenarios need someone to edit or stage files in the session archive by hand. That makes them at most [P3 nit] under this repo's review rules, but checking content closes this class rather than single cases.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Validate added files before accepting a migration retry — src/session-flow/sync.ts:561. With both directories present, stage an unrelated deletion under %XX and a new file under ~XX, then push session B. Every A is accepted without checking its content or correspondence to a removal, so both unrelated changes are committed despite no failed migration. This also accepts edits staged to newly migrated files after a rejected commit. Verify the actual migration rather than accepting arbitrary additions.

  • [P1 blocking] Recover failed folds when the push updates an existing session — src/session-flow/sync.ts:561. Suppose session A is under %XX and session B already exists under ~XX. Push an update to B and reject the migration commit with a hook. On retry, B’s staged metadata is M with content absent from the old-directory blobs, so recovery rejects this genuine migration. The scoped commit publishes B but leaves A’s move uncommitted. Recovery must account for existing sessions updated by the failed push.

The specific scenarios covered by the earlier findings are resolved; the remaining retry gaps are described above.

The description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

…mmit fails, instead of recovering a staged move
@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed in b4e47f9 by removing retry recovery instead of tightening it again. The second finding was a real regression: a genuine retry was rejected.

The move is now a commit of its own (sessions: move <%XX> to <~XX>), made before the session is written. When that commit fails, the move is undone on disk and in the index, so a half-done move is never left to recover. With nothing to recover, no staged state has to be read as a retry:

  • arbitrary staged additions under ~XX are never accepted;
  • a failed push that updates a held session cannot strand the move.

Both directories are still left alone while either has a local change to a committed file, staged or not. gitCommit is back to its form on main.

Tests:

  • undoes a move whose commit is rejected, and moves the archive on a later push, leaving unrelated files out
  • undoes a fold whose commit is rejected while a push updates a held session, and folds on a later push, which is the second finding's scenario
  • publishes no edit made between a rejected move and the next push
  • the staged-change tests from earlier rounds, which still pass

@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Repair the destination index before deduplicating — src/session-flow/sync.ts:584. If the ~XX directory’s index is missing or corrupt in HEAD, readIndex returns no sessions. Folding another copy of an existing session from %XX then creates an _1 duplicate instead of keeping the later push. Use the existing repairing read before constructing held.

  • [P3 nit] Check each source file against the committed paths — src/session-flow/sync.ts:621. A tracked .meta.json with an untracked sibling .jsonl passes the metadata-only guard. Folding then adds and commits that local-only transcript during an unrelated push. Check committed membership for each file moved.

  • [P3 nit] Handle an untracked-only destination during rollback — src/session-flow/sync.ts:563. If ~XX exists only through untracked files and the migration commit is rejected, checkout fails because toRel has no match in HEAD. That failure is swallowed, and cleanup deletes the moved sessions without restoring their originals. Restore only paths present in HEAD.

All earlier findings are resolved by the current rollback-based migration.

The description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. The older tested commit is a note, not a blocker; additional agents/providers are deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed in 319b494.

  • [P2] Missing or corrupt ~XX index: the fold now reads it through readIndexForWrite, which repairs it before building the held set. Test: folds a session held under the current name once, even when that directory has no index. It was red before the fix and produced an _1 duplicate.
  • [P3] Untracked sibling of a committed meta: each file is checked against the committed paths, so a local-only .jsonl is neither moved nor removed.
  • [P3] Rollback with an untracked-only ~XX: the rollback checks out each name on its own, and ~XX only when HEAD holds files there. This one could leave a session missing from disk, so it is worse than a nit. Test: restores the percent-escaped directory when a rejected fold had only untracked files under the current name. It was red before the fix: the session was gone from disk.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Only deduplicate against committed destination files — src/session-flow/sync.ts:620. If ~XX contains an untracked copy of session A with an equal or later pushedAt, its index entry enters held. Pushing unrelated session B then deletes A’s committed %XX files, but never adds the untracked replacement, so A disappears from the remote. Check destination files’ committed membership before treating them as deduplication winners.

The previously reported findings are resolved in the current diff.

The description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in f50aac2. A held session counts as the dedup winner only when its files are committed under ~XX. Otherwise the %XX copy is neither moved nor removed, so it stays in HEAD. Test: keeps a session under the percent-escaped name when the current name holds it only as untracked files. It reproduces the case by leaving ~XX without a committed index, so the index repair reads the local-only copy as held. It was red before the fix: the session left HEAD.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Check both destination files before deduplicating — src/session-flow/sync.ts:622. If a held ~XX session has committed metadata but an untracked transcript, this guard accepts it. When its pushedAt is equal or later, folding deletes the committed %XX transcript without committing its replacement; when the source is newer, it overwrites the local-only transcript. Check committed membership for both destination files.

  • [P3 nit] Exclude local-only sessions from the migration index — src/session-flow/sync.ts:640. With a valid committed destination index and additional untracked session files, this rebuild adopts those local-only sessions. The migration then commits their index entries without their files, exposing local metadata and listing sessions whose transcripts are absent from the remote. Exclude untracked pairs from the published index.

The previously reported scenarios are resolved in the current diff.

The description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only; no PR code was run, built, or installed.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This was generated by AI during triage.

Approving at f50aac2. Thanks for moving to the separate move commit; it closes the class of retry bugs from the earlier rounds instead of one case at a time.

Checked with the real CLI at 319b494 (local bare remote, the merge-base build as the older CLI), and the f50aac2 interdiff read on top:

  • A member's local edits and deletions under %XX (unstaged, staged, or the whole directory removed) stay local. The directory is left in place until it is clean.
  • A move commit rejected by a hook is undone on disk and in the index; the next push moves the directory and nothing is lost or published early.
  • Sessions an older CLI pushes under %XX after the move fold into ~XX and are listed; a re-pushed session is stored once. A stale old-CLI clone that pushes after the move stops on git's rename conflict and succeeds after a pull, which is acceptable for a rollout and is covered by the CHANGELOG line.
  • A missing or corrupt ~XX index is repaired before the fold, and f50aac2 keeps a %XX session in place when ~XX holds it only as local files.

The CI review at f50aac2 has two [P3 nit] items, both needing untracked files placed in the archive by hand (a held session with a committed .meta.json but an untracked .jsonl; untracked session files picked up by the index rebuild). I agree they are nits; worth a follow-up if you want the fold to ignore untracked files entirely.

This merges once CI is green at this head.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in ba89818. A held session now wins the dedup only when both its .jsonl and .meta.json are committed under ~XX. This extends the guard from f50aac2, whose test covers the meta case.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This was generated by AI during triage.

Re-approving at ba89818: the one-line change requires both files of a held ~XX session to be committed before it wins the fold, which closes the first [P3 nit] from the last CI review. Merges once CI is green at this head.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Exclude local-only sessions from the migration index — src/session-flow/sync.ts:640. With a valid committed ~XX index and additional untracked session pairs, rebuilding adopts those local-only sessions. The migration commits their index entries without their files, exposing local metadata and listing transcripts absent from the remote. Filter the published index to committed destination pairs and files moved by this fold. This earlier finding remains unresolved.

The earlier destination-pair deduplication finding is resolved; the other previously resolved findings remain resolved.

The PR description documents sufficient testing: a build followed by representative real-CLI migration and mixed-version pushes against a bare remote. Additional agents/providers are explicitly deferred to CI.

Review was read-only and limited to the specified diff; no PR code was run, built, or installed.

@hz89Dev

hz89Dev commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Leaving this one as is. The fold rebuilds the ~XX index with the same rebuildIndexLocked that main already uses whenever a push repairs a missing or corrupt index (readIndexForWrite). That repair also lists every session pair on disk, committed or not, and commits the result. Filtering only in the fold would make the two paths disagree about what the index publishes.

The precondition is also narrow. A session pair can sit untracked under ~XX only after a manual copy or a push that was interrupted between writing and git add. A failed session commit leaves its files staged, and while anything is staged the move is skipped. If index rebuilds should exclude local-only sessions, that change belongs to rebuildIndexLocked for every caller, in its own PR.

@SaulMoro
SaulMoro merged commit 3da56ad into Tencent:main Oct 30, 2026
18 checks passed
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.

3 participants