feat(session): add 'ocr session rm' to delete a saved session - #1490
basil-k-aji-dev wants to merge 1 commit into
Conversation
|
✅ OpenCodeReview: Review complete: 0 finding(s) across 2 selected item(s). |
|
@NanaseInori review |
NanaseInori
left a comment
There was a problem hiding this comment.
The overall direction looks right to me. Keeping the Viewer read-only and putting deletion behind the CLI preserves the existing security boundary while solving the concrete UX problem with a much smaller capability change.
I do have one issue that I think should be fixed before merge.
runSessionRm currently calls session.LoadSummary(repoDir, sessionID) before the new session-ID validation is applied by DeleteSession.
As the PR itself notes, the existing SessionFilePath only checks that the ID is non-empty. That means a traversal-shaped ID can still be resolved and an attempted read can happen during summary loading before DeleteSession later rejects the same ID.
The delete path itself is protected well, but I think the stronger invariant for a destructive command should be:
reject an invalid session ID before performing any filesystem access derived from that ID.
Could we run the same validation before LoadSummary, preferably by reusing the validator in internal/session rather than duplicating the rules in the CLI?
There is also a smaller issue around the corrupt-session behavior described in the PR. LoadSummary currently tolerates malformed JSON records rather than necessarily returning an error, so a corrupt file can still produce a non-nil, mostly zero-value summary instead of taking the (its metadata could not be read) branch. The CLI tests currently seed sessions with {}, so they do not exercise that fallback either.
It would be good to add a genuinely malformed-session case and make sure the confirmation output remains meaningful. Similarly, a missing session could probably be reported before asking the user to confirm instead of being treated as unreadable metadata first.
Aside from those points, I like the implementation: --yes is explicit, EOF safely defaults to no, the actual deletion has traversal protection plus a second directory check, ErrSessionNotFound gives the CLI an actionable error, and the Viewer remains untouched.
Requesting changes mainly for the validation-before-read ordering; the rest looks solid.
This review was conducted by Qiyuanqiii's review bot, using the model GPT 6 Astra Max. If you need a human review, please manually @Qiyuanqiii
|
There are some issues, and I’ve already reproduced them on my local machine. |
|
@NanaseInori all three were right. Pushed in Validation ordering. This was the real one. The id is now validated at the top of if err := session.ValidateSessionID(sessionID); err != nil {
return err
}
The corrupt-session branch was unreachable, and worse than unreachable. You were right that That is the output from the new test run against the old code. A summary now counts as readable only if it carries a start time. And you were right that seeding Missing sessions are reported before the prompt. Also your point, and clearly better: confirming the deletion of something that is not there wastes the answer and hides the likely cause, a mistyped id. Both new tests fail against the previous revision:
I have left |
Qiyuanqiii
left a comment
There was a problem hiding this comment.
Thanks for fixing the validation order and corrupt-session prompt in fc9fad0. The cross-repo deletion case still reproduces on this head; I'd like to fix that before merging. Details inline.
| dir, err := SessionsDir(repoDir) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| path := filepath.Join(dir, sessionID+".jsonl") |
There was a problem hiding this comment.
There's a cross-repo deletion case here. encodeRepoPath maps /tmp/a-b/c and /tmp/a/b-c to the same directory. A session saved for B then shows up in A's session list, and running ocr session rm <id> --repo A --yes deletes B's file. This still reproduces on fc9fad0 with equivalent paths on Windows.
Could we check the recorded repository before removing the session, and add a test with two colliding paths? I'd fix this before merging, since the existing listing issue now leads to data loss.
One detail to watch: LoadSummary().RepoDir falls back to the caller's repo when no cwd was read. Comparing that field alone would let records with missing metadata pass the check.
There was a problem hiding this comment.
Adding the local repro details for fc9fad0: Windows/amd64, Go 1.26.5. Paths below are shortened to <tmp>.
I initialized two temporary Git repos:
- A:
<tmp>\a-b\c - B:
<tmp>\a\b-c
With HOME and USERPROFILE pointing to a temporary profile, I placed a synthetic JSONL session in SessionsDir(B). Its session_start record has cwd set to B and the session ID 20260920-050000-repo-b-latest. Both repo paths map to the same sessions directory.
Running this against A:
ocr session list --json --repo "<tmp>\a-b\c"
returns B's session. Relevant fields from the output:
{
"session_id": "20260920-050000-repo-b-latest",
"repo_dir": "<tmp>\\a\\b-c",
"git_branch": "repo-b-branch"
}Then:
ocr session rm 20260920-050000-repo-b-latest --yes --repo "<tmp>\a-b\c"
Deleted session 20260920-050000-repo-b-latest
The command exited with code 0 and empty stderr. I also checked the filesystem: B's session file was gone. I'd expect the repo mismatch to be rejected and the file to stay in place.
There was a problem hiding this comment.
Same here: I answered this in the conversation tab and left the thread looking unread.
Your two colliding repos, <tmp>\a-b\c and <tmp>\a\b-c, are the case the code is built around now. CheckSessionRepo reads the recorded cwd and refuses when it does not match, and since your report it also refuses when there is no recorded cwd to check, which is where it used to fall open.
The wider answer to the collision is that --repo is no longer the way in. ocr session rm <id> finds the file by id, so the encoded directory stops being load-bearing for the common path. It is still load-bearing for #894, which is the real fix and is somebody's to make.
|
@Qiyuanqiii you are right, and thank you for reproducing it — this was data loss introduced by my command, not a pre-existing fault it merely exposed. Fixed in I confirmed the collision first: Two such repositories have always seen each other's sessions in
Your warning about A session with no recorded cwd stays deletable. It cannot be attributed to either repository, and it is one a user is especially likely to want gone; refusing would strand it with no way to clean it up. If you would rather that case be refused too, say so and I will change it — it is a one-line decision and yours to make. The CLI explains the refusal rather than surfacing the raw error: I have not fixed the encoding. Making Tests: the colliding-repo case is refused and the file survives, while remaining deletable from the owning repository; and an unattributable session is still removable. The first fails against the previous revision:
|
|
Thanks for the Windows repro — that is precisely the shape Your synthetic session has The test I added builds the same two paths ( One platform caveat I should state: I have only run this on Linux. |
| // string equality, so a trailing separator or an uncleaned path does not read | ||
| // as a different repository. | ||
| func sameRepoPath(a, b string) bool { | ||
| return filepath.Clean(a) == filepath.Clean(b) |
There was a problem hiding this comment.
Retested 2b5800e on Windows. The cross-repo deletion is now refused and B's file stays intact. Deleting from B works too. The session, CLI and Viewer package tests all passed.
I did hit one Windows regression here. Changing just the drive letter from D: to d:, or the directory name from a-b to A-B, makes deletion fail with "session belongs to a different repository". I checked that both paths refer to the same directory on this machine. The same cases succeed on fc9fad0.
filepath.Clean preserves letter case, so this comparison treats those paths as different repositories. Please compare directory identity where possible and add tests for both case variations, while keeping genuinely different directories distinct on case-sensitive filesystems.
This is the remaining issue holding up my approval.
There was a problem hiding this comment.
Closing this one in the thread, since my answer went to the conversation tab and left this looking unread.
Both regressions you found here are fixed and have stayed fixed through every rework since. sameRepoPath asks os.SameFile rather than comparing spellings, which is why D: and d: agree while a-b and A-B still do not, and I kept the fallback you argued for: when a directory cannot be stat-ed and the paths differ, the session is left alone.
What has changed since you tested 32b4875 is worth knowing before you look again. --repo is no longer required at all, so the attribution check you drove now runs on the narrowing path rather than the common one, and it fails closed when a session records no repository, which it did not when you tested it. The chmod-based unreadable-session test also skips where the mode is not enforced, which is your Windows finding.
|
@Qiyuanqiii thank you for retesting on Windows — that regression was mine and I could not have found it here. Fixed in You are right that this cannot come from string comparison. So identity is now asked of the filesystem rather than inferred from the spelling: func sameRepoPath(a, b string) bool {
ca, cb := filepath.Clean(a), filepath.Clean(b)
if ca == cb {
return true
}
fa, errA := os.Stat(ca)
fb, errB := os.Stat(cb)
if errA != nil || errB != nil {
return false
}
return os.SameFile(fa, fb)
}
One case needs a decision rather than a mechanism: if either directory can no longer be stat-ed — the recorded repository was moved or deleted — identity cannot be established and only the strings remain. An exact match still passes; anything else is treated as a different repository, which refuses the delete rather than risking the wrong file. That is a deliberate choice to fail closed, and if you would rather it fell back to a case-insensitive compare on those platforms, say so. What I can and cannot verify. Five subtests: trailing separator and dot segments, a symlink to the same directory, a case variation, genuinely different directories, and a vanished directory. The symlink case fails against the previous revision: The case-variation subtest skips on this machine, and says so, because Linux is case-sensitive and So the case that you reported is the one I cannot exercise. The symlink subtest is the closest proxy available to me — it takes the same
|
|
Looks good on Windows now. I retested
I'd keep the current fallback when a directory can't be stat-ed. If the paths differ and we can't establish that they refer to the same directory, leaving the session alone makes sense. There's no need to add a case-insensitive fallback for that. That covers the two issues I raised. |
|
Thanks for picking up this request. I don't mind a The best option for me is a button on the session list page on viewer, don't bother with a shell command. |
|
@iredmail that is fair, and worth saying clearly: this PR does not give you what you asked for. You asked for a button and this is a shell command, which for your workflow is strictly worse than the For the maintainers, the state of the disagreement as I understand it: @Qiyuanqiii declined the Viewer button on the grounds that the read-only property is a tested security invariant — So the question is whether a CLI command is worth having on its own terms. I think there is a case, and it is not the one I would have made yesterday:
Neither of those helps @iredmail, whose objection is about ergonomics, not safety. One thing I could add if it is wanted.
@Qiyuanqiii @lizhengfeng101 — happy to add that, leave this as is, or close it if the CLI is not the shape you want for this. I have no attachment to it landing. |
|
If he needs this feature, please open a separate PR. The final merge decision is up to lizhengfeng. |
lizhengfeng101
left a comment
There was a problem hiding this comment.
Nice call on keeping the Viewer read-only, and the ErrSessionOtherRepo catch is a genuinely sharp one — encodeRepoPath not being injective is easy to miss. Four things before this lands:
1. The CLI reference isn't updated. All five existing session subcommands have both a summary-table row and a ### ocr session <x> section in pages/src/content/docs/<locale>/cli-reference.md, across en/zh/ja/ru/ko. This is the sixth and has neither. Worth documenting --yes and the "non-interactive stdin answers no" rule explicitly, since that behaviour is invisible otherwise. Adding a TestCLIReferenceDocumentsSessionRm pin alongside the compare/export ones is optional but would match where the repo has been heading.
2. The ownership check runs after the prompt. In the directory-collision case the user is shown the other repo's branch and start time, answers y, and only then gets refused. That's the same wasted answer you (rightly) avoid for a missing id — so either check attribution before prompting, or at minimum print summary.RepoDir so the collision is visible in the prompt. The ErrSessionOtherRepo branch in runSessionRm also has no CLI-level test.
3. The prompt doesn't reuse the list formatting. StartTime.Format(time.RFC3339) vs describeStart's local 2006-01-02 15:04:05 means the timestamp in the prompt doesn't match the one the user just read in ocr session list — which is exactly where they got the id. Same for the file count (describeFiles). And TotalComments is arguably the number that decides whether a session is worth keeping, so it'd be good to show it.
4. Non-ENOENT read errors get folded into "corrupt". Only fs.ErrNotExist is handled; a permission error or a real I/O failure also lands on readable = false and the delete proceeds. "A corrupt record must stay deletable" is right, but that case is no error with nothing parsed — a hard read failure is a different thing, and on a destructive command I'd rather it surfaced than be described as unreadable metadata.
Also, per the contributing rules: the PR description needs to disclose the AI/LLM tools and models used. And the series is one feat plus three fix commits over the same new code — worth squashing; the reasoning in those commit messages is already well captured in the description.
| func DeleteSession(repoDir, sessionID string) error { | ||
| if err := ValidateSessionID(sessionID); err != nil { | ||
| return err | ||
| } | ||
| dir, err := SessionsDir(repoDir) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| path := filepath.Join(dir, sessionID+".jsonl") | ||
|
|
||
| // Belt and braces: ValidateSessionID already forbids traversal, but the | ||
| // cost of being wrong here is deleting a file outside the store, so the | ||
| // resolved path is confirmed to sit directly in the sessions directory | ||
| // before anything is removed. | ||
| if filepath.Dir(path) != filepath.Clean(dir) { | ||
| return fmt.Errorf("invalid session id %q: resolves outside the sessions directory", sessionID) | ||
| } | ||
|
|
||
| if _, err := os.Stat(path); err != nil { | ||
| if os.IsNotExist(err) { | ||
| return fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) | ||
| } | ||
| return fmt.Errorf("stat session %q: %w", sessionID, err) | ||
| } |
There was a problem hiding this comment.
One question: Do you have to be inside the session directory to be able to delete that session?
There was a problem hiding this comment.
No — you never go near the session directory. Sessions live under ~/.opencodereview/sessions/<encoded-repo-path>/, and nothing in the command asks you to be there.
What you do need is to be inside the repository the session belongs to, or to point at it with --repo:
cd ~/work/my-project
ocr session rm 20250601-100000-abc123
# or from anywhere
ocr session rm 20250601-100000-abc123 --repo ~/work/my-projectThat is the same resolution ocr session list, show, comments, compare and export already use — resolveWorkingDirForSession resolves a repository root, and SessionsDir turns that into the storage directory. rm adds no new way of naming a session.
The reason repoDir reaches this far down rather than being resolved once and forgotten is the check on the line you commented on. Two different repositories can encode to the same sessions directory, because encodeRepoPath replaces separators with -: /tmp/a-b/c and /tmp/a/b-c both become tmp-a-b-c. So a session file sitting in the directory your repository resolves to is not proof that it is your session. CheckSessionRepo reads the cwd the session recorded and compares it against the repository you asked to delete from, and refuses if they differ.
So the honest summary is the opposite of your question: the session directory is not where you stand, it is the thing that cannot be trusted on its own.
There was a problem hiding this comment.
Yes that is what I mean: You have to be in the dir where you executed ocr review. I don't think that is nessicary.
There was a problem hiding this comment.
You are right, and it is not a small point. Two separate things, and I want to be honest that one of them is a real limitation rather than a design I would defend.
The link is removed. I have changed Closes #1468 to Refs #1468 in the description. You were right that it should not have been there: merging this would have auto-closed @iredmail's request for a Viewer button while shipping a shell command instead, which is the request being closed rather than answered.
On having to stand in the repository. The requirement is not that you be where you ran ocr review; it is that the repository be named, which --repo does from anywhere:
ocr session rm 20250601-100000-abc123 --repo ~/work/my-projectBut that is a smaller correction than your objection deserves, because your real question is why the repository has to be named at all when the session id already identifies the session uniquely. It does not, inherently. It has to be named because of how sessions are stored:
SessionsDir(repoDir)derives the directory from the repository path, so without a repository there is no directory to look in. Answeringrm <id>alone means scanning every directory under~/.opencodereview/sessions/.- That scan is the part I would not want to write into this PR.
encodeRepoPathis not injective —/tmp/a-b/cand/tmp/a/b-cboth encode totmp-a-b-c— so a global search can return two sessions with the same id from different projects, and a destructive command that has to guess between them is worse than one that asks you to say which project you mean.
So the honest shape of it: the constraint is inherited from the storage layout, --repo is the workaround, and a repo-less ocr session rm <id> is a reasonable thing to want that needs the collision fixed first. If the maintainers would rather see the collision fixed before any of this, I think that is a defensible order and I would take that issue.
Every other session subcommand — list, show, comments, compare, export — resolves a repository the same way, so this is not a new constraint this command introduces. That is an explanation of why it is consistent, not an argument that it is convenient.
There was a problem hiding this comment.
Sure, viewer should be read only in my opinion.
There was a problem hiding this comment.
@basil-k-aji-dev Even if you have a flag named —repo, I suggest getting rid of of the limitation. My plan: do NOT block users when they run the rm command, just DO IT.
There was a problem hiding this comment.
Coming back to this one, because it deserved a reply in the thread rather than only in the commit.
You were right and I did it. --repo is no longer a limitation: ocr session rm <id> looks under every repository and works from anywhere, and --repo only narrows the search.
The one place I did not go all the way to "just DO IT" is when the id matches in two repositories at once. There the command lists them and deletes nothing, because encodeRepoPath replaces separators with -, so /tmp/a-b/c and /tmp/a/b-c share a sessions directory and one id can name two different files. Doing it anyway would be a coin flip on something unrecoverable. Your later point about failing closed when a session records no repository at all is the same instinct, and I took that too.
|
Kind reminder: don't over design. No one needs this |
|
I suggest don't implement |
|
@iredmail fair, and I am not going to argue you out of it — I already said above that this PR does not give you what you asked for, and that stands. You wanted a button; this is a shell command, and for your workflow it is worse than the I have made the case I have for it once and will not repeat it. Whether it is worth having is @lizhengfeng101's call, and if the answer is no I would rather this close than be argued into the tree. One thing I would rather not leave sitting in the thread, though, since it affects you either way: the cross-repo bug @Qiyuanqiii found is in the storage layout, not in my command. |
|
If so please remove that issue from development @basil-k-aji-dev |
32b4875 to
7ae8034
Compare
|
@lizhengfeng101 all four are done, plus the two process points. The branch is now a single commit, 1. CLI reference. Both the summary-table row and a I took the optional pin too. 2. Ownership check before the prompt. You were right that this is the same wasted answer as a missing id, and printing 3. Prompt formatting. It now uses 4. Corrupt vs unreadable. Separated. A session whose metadata will not parse stays deletable, because that is the record most likely to need removing; a session that cannot be read now returns Squashed. One AI/LLM disclosure is now in the description, naming the tool and model. That was my omission against rule 1 and you were right to ask. One thing that changed for a different reason while you were reviewing: @wu21-web pointed out that Unchanged since my last note: I have only run this on Linux. @Qiyuanqiii retested |
|
Since the final implementation now includes the cross-repository ownership protection and related behavior that came out of my review/reproduction, would you be comfortable adding me as a co-author on the squashed commit? If you prefer to keep authorship limited to code authors, a Reported-by / Reviewed-by / Tested-by acknowledgement is also fine with me. |
|
I'll rerun the Windows checks on |
|
@Qiyuanqiii yes, and thank you for asking rather than letting it go — you should not have had to. The whole cross-repository half of this PR is yours. You found that That is co-authorship of the design, and the commit should say so rather than leaving it in a thread that nobody reads after merge. One thing I would rather ask than guess: which email address do you want on the trailer? The trailer only attaches to your account if the address is one GitHub already knows; get it wrong and it renders as plain text and credits nobody, which quietly loses exactly the attribution it is meant to record. The usual form is: Reply with the address you want and I will amend the squashed commit. Only the message changes — the tree is identical, so nothing needs re-reviewing. @NanaseInori and @lizhengfeng101, the same offer stands to you if you want it. @NanaseInori, the validation-before-any-filesystem-access ordering and the corrupt-versus-unreadable split are both your findings; @lizhengfeng101, the pre-prompt ownership check changed the shape of |
|
Just to clarify, @NanaseInori is my bot account. According to the repository rules, bots can’t be co-authors. |
|
Retested the Windows path checks on 7ae8034. Drive-letter and directory-name case variations work, and cross-repo deletion is refused before the prompt. |
Sounds good. Please use this for the trailer: |
7ae8034 to
0ee876e
Compare
| if recorded, ok := recordedRepoDir(path); ok && !sameRepoPath(recorded, repoDir) { | ||
| return fmt.Errorf("%w: %s was recorded for %s", ErrSessionOtherRepo, sessionID, recorded) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
| if recorded, ok := recordedRepoDir(path); ok && !sameRepoPath(recorded, repoDir) { | |
| return fmt.Errorf("%w: %s was recorded for %s", ErrSessionOtherRepo, sessionID, recorded) | |
| } | |
| return nil | |
| recorded, ok := recordedRepoDir(path) | |
| if !ok { | |
| return fmt.Errorf( | |
| "cannot verify repository for session %s: no repository was recorded", | |
| sessionID, | |
| ) | |
| } | |
| if !sameRepoPath(recorded, repoDir) { | |
| return fmt.Errorf( | |
| "%w: %s was recorded for %s", | |
| ErrSessionOtherRepo, | |
| sessionID, | |
| recorded, | |
| ) | |
| } | |
| return nil |
--repo still fails open when the session has no recorded cwd
There was a problem hiding this comment.
You are right, and I have taken it. Pushed.
--repo failed open: when recordedRepoDir returned ok == false, the check fell through to return nil and the delete went ahead. So --repo A could remove B's session through a colliding directory, on the strength of the directory rather than of anything the session said about itself. The directory is exactly what @Qiyuanqiii showed is not proof.
I did not take the wording verbatim, because the refusal has to point somewhere. It is a distinct error now:
Error: session records no repository: 20250601-100000-nocwd records no repository,
so it cannot be verified as this one; delete it by id alone, without --repo
Run 'ocr session rm 20250601-100000-nocwd' without --repo: the id locates the file on
its own, so nothing has to be verified against a repository.
ErrSessionUnverifiable rather than ErrSessionOtherRepo, because the answer for the caller is different: this one is fixed by dropping a flag, not by naming a different repository.
The part worth checking me on. This reverses a decision I made earlier in this same PR. A session that records nothing is usually a damaged one, and I had argued a damaged record is the one people most want gone, so it must stay deletable. Failing closed looked like it would take that away.
It does not, because the global lookup you and @Qiyuanqiii asked for landed first. ocr session rm <id> with no --repo deletes the file the id found, and needs no attribution at all, precisely because the id located it rather than a repository path doing so. So the damaged record stays deletable by the shorter command, and the longer one stops guessing. Those two properties only fit together in that order, which is why your earlier request had to come first.
Two new tests, both run against the unpatched revision first: TestSessionRm_RepoFlagRefusesAnUnverifiableSession and TestSessionRm_UnverifiableSessionIsStillDeletableByIDAlone. The old TestDeleteSession_UnattributableSessionIsStillDeletable now asserts the refusal and carries a comment saying #1490 reversed it and why, so nobody reads it later as an accident. Docs updated in all five locales.
46993b5 to
ac930af
Compare
| Long: "Delete a persisted review session from ~/.opencodereview/sessions/.\n\n" + | ||
| "The session id is enough on its own: the command looks for it under every\n" + | ||
| "repository, so it works from anywhere. If the id is saved for more than one\n" + | ||
| "repository it lists them instead of guessing, and --repo narrows the search.\n\n" + | ||
| "The session file is removed from disk and cannot be recovered, so the command\n" + | ||
| "asks for confirmation unless --yes is given. The Viewer deliberately stays\n" + | ||
| "read-only; deletion lives here, where the caller is already the owner of the\n" + | ||
| "files.", |
There was a problem hiding this comment.
Fair, and cut. It was eight lines carrying three ideas and an argument about the Viewer that belongs in the PR, not in --help:
Delete a persisted review session from ~/.opencodereview/sessions/.
The id is enough on its own, so this works from anywhere; --repo only narrows
the search. An id saved for more than one repository is listed, not guessed at.
The file cannot be recovered, so this confirms first unless --yes is given.
Four lines, and closer in length to ocr session export next door.
| Example: " ocr session rm 20250601-100000-abc123\n" + | ||
| " ocr session rm 20250601-100000-abc123 --yes\n" + | ||
| " ocr session rm 20250601-100000-abc123 --repo ~/work/my-project", |
There was a problem hiding this comment.
This is outdated isn't it?
| Example: " ocr session rm 20250601-100000-abc123\n" + | |
| " ocr session rm 20250601-100000-abc123 --yes\n" + | |
| " ocr session rm 20250601-100000-abc123 --repo ~/work/my-project", | |
| Example: " ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b\n" + | |
| " ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --yes\n" + | |
| " ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --repo ~/work/my-project", |
There was a problem hiding this comment.
Yes, and thank you for spotting it. Taken verbatim.
generateUUID is a v4, so a real id is 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b. 20250601-100000-abc123 was never a shape this tool produces. I copied it from ocr session export, which has carried it since #1174, so the example was wrong before I got here and I spread it rather than checking it against generateUUID.
Fixed in the rm help and in all five locales of the CLI reference, and the docs guard test now pins the UUID form so it cannot drift back.
ocr session export still has the old shape, in its help text and in all five locales. I have left it alone because this PR has nothing to do with export and I would rather not widen it under review. It is a handful of lines whenever someone wants them; say the word if you would rather have it here.
ac930af to
7a25f70
Compare
|
@wu21-web both of your review points are in, pushed. The The examples use a real id. You were right that Also in from your earlier thread:
|
wu21-web
left a comment
There was a problem hiding this comment.
I reviewed the documentation. They were explaining why more than how to use those flags.
| Deletes one persisted session from `~/.opencodereview/sessions/`. The Viewer is | ||
| deliberately read-only, so deletion lives here, where the caller already owns |
There was a problem hiding this comment.
What is the point of mentioning viewer here? This is our discussion for the development, you shouldn't have written it into the docs.
There was a problem hiding this comment.
Removed. You are right that it was our design discussion rather than something a reader needs, and it has no place in a usage reference. The whole section is rewritten to say how to use the command, not why it exists.
| separators with `-` and two repository paths can share one session directory: | ||
| guessing between them would be a coin flip on a file that cannot be recovered. | ||
| `--repo` narrows the search to one repository and resolves that case. | ||
|
|
There was a problem hiding this comment.
These are unnecessary explainations.
There was a problem hiding this comment.
Cut. encodeRepoPath, the directory-collision reasoning and the coin-flip line are all gone. What is left is: the id works from anywhere, several matches are listed, pass --repo to pick one.
|
|
||
| ### `ocr session rm` | ||
|
|
||
| 从 `~/.opencodereview/sessions/` 中删除一个已持久化的会话。查看器被刻意设计为 |
There was a problem hiding this comment.
| 从 `~/.opencodereview/sessions/` 中删除一个已持久化的会话。查看器被刻意设计为 | |
| 从 `~/.opencodereview/sessions/` 中删除一个已保存的会话。查看器被刻意设计为 |
There was a problem hiding this comment.
Taken, and the sentence you were trimming is gone entirely along with the rest of the Viewer explanation.
| 分隔符替换成 `-`,两个仓库路径可能共用同一个会话目录,在它们之间猜测等于对一个 | ||
| 无法恢复的文件掷硬币。`--repo` 把搜索范围缩小到一个仓库,从而解决这种情况。 |
There was a problem hiding this comment.
Please remove the story about the coin. This translation is not quite accurate. You should review translations yourself before submission.
There was a problem hiding this comment.
The coin story is gone, from all five locales.
On the translation: you are right, and it is the criticism I have least defence against. I wrote the zh, ja, ru and ko prose myself and cannot check any of them to a native standard, so "review translations yourself before submission" is exactly the step I could not actually perform. What I did instead was write long explanatory prose, which is the worst possible thing to do when you cannot verify the result: more sentences, more room to be wrong.
The rewrite cuts each locale to roughly a third of what it was and keeps the sentences short and declarative, which at least reduces the surface. That is damage limitation rather than a fix. If any of the four still reads badly, a suggestion like the one you left on line 476 is the fastest way to correct me, and I will take it verbatim.
| `ocr session list` 一致,因此你是针对"即将失去什么"作答,而不是针对一个看不懂 | ||
| 的 id。 | ||
|
|
||
| `--yes`(`-y`)跳过确认。**非交互式的 stdin 一律视为"否"**:管道或 CI 任务会 |
There was a problem hiding this comment.
Please mention non-interactive system to be no by default before you introduce the yes flag because this is confusing.
There was a problem hiding this comment.
Reordered, in all five locales. The stdin rule now comes first and --yes is introduced as the answer to it, in one sentence rather than two:
The session's repository, branch, start time, file count and comment count are printed, and you are asked to confirm. A non-interactive stdin answers no, so a pipeline or a CI job must pass
--yes(-y) to skip the prompt.
You were right that the old order was confusing. It introduced --yes as a convenience, then took it back a sentence later by saying some callers have no choice, which reads like a correction rather than a rule.
|
|
||
| | 参数 | 默认值 | 说明 | | ||
| |---|---|---| | ||
| | `--repo <path>` | 所有仓库 | 只在该仓库下查找这个会话。 | |
There was a problem hiding this comment.
I suggest adding a shorthand like -R in the design of gh api, since one command has its own flags. That is optional anyway.
There was a problem hiding this comment.
I have not added -R, and since you marked it optional I would rather explain than quietly skip it.
All six session subcommands take --repo: list, show, comments, compare, export and now rm. None has a shorthand. Adding one to rm alone would make it the only one, so someone who learns -R here would find it missing everywhere else, which is worse than not having it.
It is a good idea though, and the reason to want it is the same for all six. If you would like it, it is a small change across the set and I am happy to send it as its own PR rather than smuggle a cross-command flag change into this one. gh is the right precedent: -R is uniform across every gh command that takes a repository, which is exactly what makes it worth learning.
7a25f70 to
86c29d5
Compare
|
@NanaseInori review |
|
@wu21-web all six points are in, pushed. The docs section is about a third of its former length. Gone: the Viewer rationale, The one I did not do is On translations you were right, and it is the criticism I have least defence against, since I cannot check zh, ja, ru or ko to a native standard. Shortening each locale is damage limitation, not a fix. A suggestion like the one you left on line 476 is the fastest way to correct me and I will take it verbatim. |
NanaseInori
left a comment
There was a problem hiding this comment.
I re-reviewed the latest head (86c29d5). The earlier ownership, Windows path-identity, unreadable-session, documentation, and confirmation issues all look addressed, and the new repo-less lookup is much more convenient than requiring repository context for the common case.
I found one safety invariant that the new global deletion path does not currently preserve.
The repository-scoped path is deliberately defensive: DeleteSession() derives the target through SessionsDir() / sessionPath() and confirms that the resolved file sits in the expected sessions directory before removing it.
The new repo-less path eventually calls DeleteSessionAt(Location). That function validates the session ID and checks only that:
filepath.Base(loc.Path) == loc.SessionID + ".jsonl"
It then calls os.Remove(loc.Path) directly.
As a result, DeleteSessionAt itself will accept an arbitrary path outside ~/.opencodereview/sessions/ as long as its basename matches the supplied session ID. For example, an internally constructed Location{SessionID: "victim", Path: "/some/unrelated/place/victim.jsonl"} would delete that unrelated file.
I don't see a way for a CLI user to directly forge Location.Path today, because the current caller obtains locations from FindSessionsByID(). So I am not treating this as a currently exposed arbitrary-file-deletion vulnerability. My concern is that the destructive primitive has stopped enforcing the path boundary itself and now relies entirely on caller provenance.
That seems inconsistent with the defense-in-depth approach used by DeleteSession, especially for an operation that cannot be undone.
Could DeleteSessionAt validate that the target is structurally inside the OCR session root (for example exactly ~/.opencodereview/sessions/<encoded-dir>/<session-id>.jsonl) before removing it, or reconstruct the deletion path from validated location components rather than trusting an arbitrary Path field?
A regression such as TestDeleteSessionAt_RefusesPathOutsideSessionsRoot would make that invariant explicit.
Two non-blocking notes:
ocr session rm <id>now searches globally by default, but its shell completion still uses the sharedcompleteSessionIDs, which only offers sessions for the current/--reporepository. A dedicated global completion forrmwould better match the new command semantics.- If an old duplicate session ID is ambiguous and one recorded repository no longer exists,
--repomay not be usable to select that historical entry because repository resolution requires the path to exist. This is probably rare with UUID-era IDs, so I would not block this PR on it.
Other than the path-boundary issue above, I don't currently see another implementation blocker. The previous cross-repository deletion case is now fail-closed, repository identity uses filesystem identity where available, sessions without recorded ownership are refused under --repo but remain deletable by ID, hard read errors stop the destructive operation, malformed metadata remains removable, and the five localized CLI references now match the implemented interaction model.
CI, Pages CI, translation-sync, and plugin-contract are green on the current head; CodeQL is still running at the time of this review.
86c29d5 to
090b556
Compare
|
@NanaseInori you are right, and it is worse than "inconsistent with I reverted So it was not only that the primitive stopped enforcing the boundary. A The fix takes your second suggestion rather than your first. Validating the string would have left want := filepath.Join(root, loc.EncodedDir, loc.SessionID+".jsonl")
if filepath.Clean(loc.Path) != want {
return "", fmt.Errorf("%w: %s is not %s", ErrSessionOutsideRoot, loc.Path, want)
}
Your first non-blocking note is in too. You were right that Your second one I have not fixed, and I think you are right not to block on it. If an ambiguous id's recorded repository no longer exists, Thank you for re-reviewing the whole head rather than only the diff since your last pass. The ownership, Windows path-identity and unreadable-session findings were all yours originally, and checking they survived four reworks is the part that is easy to skip. |
Qiyuanqiii
left a comment
There was a problem hiding this comment.
The Korean documentation is still out of sync with the current English version.
This is more than a wording/style issue. Several points that were already removed or reordered after the earlier documentation review are still present in pages/src/content/docs/ko/cli-reference.md:
- It still contains the Viewer/read-only design rationale, although that discussion has been removed from the English usage documentation.
- It still explains the internal
encodeRepoPathcollision and uses the “coin flip” analogy, which was also removed from the other revised documentation. - The earlier request to explain that non-interactive stdin answers no before introducing
--yeshas not actually been applied here. The Korean text still introduces--yesfirst and explains the non-interactive behavior afterwards. - The current English documentation includes the
--repo ~/work/my-projectusage example, but the Korean section does not. - The final paragraph still contains the longer repository-attribution rationale that has already been removed from the English version.
There are also several Korean-language issues such as id 만, id 를, --repo 는, and stdin 은, where the particles should not be separated this way, plus some fairly literal/unnatural sentences such as the encodeRepoPath / coin-flip explanation.
I think the safest fix is to retranslate the current concise English section rather than editing the existing Korean prose incrementally. Right now the Korean documentation is effectively an older revision of the feature documentation.
| var ErrSessionNotFound = errors.New("session not found") | ||
|
|
||
| // ErrSessionOtherRepo reports that the session file found for an id records a | ||
| // different repository than the one being operated on. | ||
| // | ||
| // This is reachable because encodeRepoPath is not injective: separators become | ||
| // "-", so /tmp/a-b/c and /tmp/a/b-c share one directory. Two such repositories | ||
| // see each other's sessions in `ocr session list`, which is confusing but | ||
| // harmless. Deleting one of them is not harmless, so the recorded repository is | ||
| // checked before any file is removed. | ||
| var ErrSessionOtherRepo = errors.New("session belongs to a different repository") | ||
|
|
||
| // ErrSessionUnverifiable reports that a session records no repository of its | ||
| // own, so --repo cannot be checked against it. Separate from | ||
| // ErrSessionOtherRepo because the answer for the caller is different: this one | ||
| // is resolved by dropping --repo, not by naming a different repository. | ||
| var ErrSessionUnverifiable = errors.New("session records no repository") | ||
|
|
||
| // ValidateSessionID rejects ids that are not a single path element. | ||
| // | ||
| // Exported so that a caller can refuse a bad id before it performs any | ||
| // filesystem access derived from it. DeleteSession calls it too, but a command | ||
| // that reads a session before deleting it would otherwise resolve a traversal | ||
| // path first and only be stopped at the delete. | ||
| // | ||
| // A session id is interpolated straight into a filename, and this package's | ||
| // existing SessionFilePath checks only that it is non-empty. For reads that is | ||
| // merely a failed open; for a delete it would be an arbitrary file removal, so | ||
| // the check lives here rather than at the call site where it could be | ||
| // forgotten. | ||
| // | ||
| // The rule is deliberately about shape rather than character set: ids have | ||
| // changed format before (timestamp-suffix and UUID forms both exist in the | ||
| // wild) and a charset allowlist would start rejecting real sessions. What must | ||
| // never be true is that the id can address anything outside its own directory. | ||
| func ValidateSessionID(sessionID string) error { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
And clean these AI code comments up. They took up almost half of the file. @basil-k-aji-dev
There was a problem hiding this comment.
Done, and you were not exaggerating: delete.go was 33% comment lines.
internal/session/delete.go 126/377 -> 18/269
cmd/opencodereview/session_cmd.go 81/874 -> 12/805
internal/session/delete_test.go 54/386 -> 2/334
session_rm_test.go 72/606 -> 2/536
What is left is one line per exported identifier, which is what the rest of this package does. Everything else is gone: the paragraphs justifying each branch, the reasoning behind the traversal rule, the narrative above each test. Those were me explaining the review thread to the file, and you are right that they do not belong there. Test names carry it now, and anything a reader genuinely needs is in the PR description.
This is a standing change for me, not a one-off for this PR.
There was a problem hiding this comment.
You skipped the suggestion itself @basil-k-aji-dev
Use const when you can.
There was a problem hiding this comment.
Fair, I did skip it, and I have answered it properly on the thread where you repeated it: #1490 (comment)
Short version: const var is a parse error, and const alone does not work either, because errors.New returns an interface value and Go constants can only be booleans, runes, numbers and strings. I ran both to be sure rather than assert it. Your point that they are immutable in intent is right, and I offered the thing that does help, which is grouping the four into one var ( block the way the standard library reads.
The other two suggestions in that round are both in: the stat handling in FindSessionsByID, which was a real bug, and the comment cleanup.
090b556 to
ae1bf64
Compare
|
@Qiyuanqiii you are right, and the reason is worse than staleness: the Korean file never received the rewrite at all. When I rewrote the section for @wu21-web I reported it as done in all five locales. Four landed. I took your advice and retranslated the current English section rather than editing the old prose. The particles are fixed too: I have also verified it this time instead of trusting the script, by grepping all five files for the text that was supposed to be gone: The wider lesson is mine to take: I claimed five locales on the strength of a script's own output. A one-line grep would have caught it, and I had every reason to run one given I cannot read four of these files. Korean is still not a language I can check, so if the retranslation reads badly anywhere, a suggestion is the fastest way to correct me and I will take it verbatim. |
| var ErrSessionNotFound = errors.New("session not found") | ||
|
|
||
| // ErrSessionOtherRepo reports that a session records a different repository. | ||
| var ErrSessionOtherRepo = errors.New("session belongs to a different repository") | ||
|
|
||
| // ErrSessionOutsideRoot reports that a Location falls outside the sessions directory. | ||
| var ErrSessionOutsideRoot = errors.New("session path is outside the sessions directory") | ||
|
|
||
| // ErrSessionUnverifiable reports that a session records no repository to check --repo against. | ||
| var ErrSessionUnverifiable = errors.New("session records no repository") |
There was a problem hiding this comment.
These are immutible.
| var ErrSessionNotFound = errors.New("session not found") | |
| // ErrSessionOtherRepo reports that a session records a different repository. | |
| var ErrSessionOtherRepo = errors.New("session belongs to a different repository") | |
| // ErrSessionOutsideRoot reports that a Location falls outside the sessions directory. | |
| var ErrSessionOutsideRoot = errors.New("session path is outside the sessions directory") | |
| // ErrSessionUnverifiable reports that a session records no repository to check --repo against. | |
| var ErrSessionUnverifiable = errors.New("session records no repository") | |
| const var ErrSessionNotFound = errors.New("session not found") | |
| // ErrSessionOtherRepo reports that a session records a different repository. | |
| const var ErrSessionOtherRepo = errors.New("session belongs to a different repository") | |
| // ErrSessionOutsideRoot reports that a Location falls outside the sessions directory. | |
| const var ErrSessionOutsideRoot = errors.New("session path is outside the sessions directory") | |
| // ErrSessionUnverifiable reports that a session records no repository to check --repo against. | |
| const var ErrSessionUnverifiable = errors.New("session records no repository") |
There was a problem hiding this comment.
You are right that I skipped it, and I should have replied at the time rather than leaving it looking ignored. But I cannot take this one, and I would rather show you why than argue it.
const var is not valid Go. It fails at the parser:
./a.go:5:7: syntax error: unexpected keyword var, expected name
Dropping var does not help either, because the problem is the value rather than the spelling:
const ErrSessionNotFound = errors.New("session not found")./a.go:5:28: errors.New("session not found") (value of interface type error) is not constant
Go constants can only be booleans, runes, integers, floats, complex numbers and strings, and they must be computable at compile time. errors.New is a function call returning an error interface backed by a pointer, so there is no form of const that accepts it. That is why every error sentinel in the standard library is a var: io.EOF, sql.ErrNoRows, os.ErrNotExist. The same is true in this repository: ErrToolNotFound in internal/tool/definitions.go:109 is a var for the same reason.
Your underlying point is right though, and worth saying plainly: these are immutable in intent, and Go gives no way to enforce it. Any package importing this one can assign to session.ErrSessionNotFound and break every errors.Is check downstream. Go has simply never offered a way to prevent that for sentinel errors.
If the goal is to signal immutability to a reader, the convention that does work is the Err prefix plus the grouped var ( block, which is how the standard library reads. I am happy to group the four into one block if you would like that; it is a real readability improvement and costs nothing. What I cannot do is make them compile as constants.
Two other suggestions of yours in this round I have taken as-is: the stat handling in FindSessionsByID, which was genuinely wrong, and the comment cleanup.
There was a problem hiding this comment.
No harm at all, and it is an easy one to forget. Go being unable to make an error a constant is a wart rather than something obvious, and the instinct behind the suggestion was right.
The offer stands on grouping the four into a single var ( block if you would like that; it costs nothing and reads closer to the standard library.
| info, statErr := os.Stat(path) | ||
| if statErr != nil || info.IsDir() { | ||
| continue |
There was a problem hiding this comment.
The current implementation skips every stat failure, not only ENOENT.
| info, statErr := os.Stat(path) | |
| if statErr != nil || info.IsDir() { | |
| continue | |
| info, statErr := os.Stat(path) | |
| if statErr != nil { | |
| if os.IsNotExist(statErr) { | |
| continue | |
| } | |
| return nil, fmt.Errorf("stat session %q: %w", path, statErr) | |
| } | |
| if info.IsDir() { | |
| continue | |
| } |
There was a problem hiding this comment.
Correct, and taken verbatim. Pushed.
statErr != nil || info.IsDir() swallowed everything: a permission error on the directory, an I/O error, anything. The id then reports as saved nowhere, and on a delete command that reads as "there is no such session" when the truth is "I could not look".
TestFindSessionsByID_ReportsAStatFailureRatherThanSkippingIt covers it, and against the old line it fails with got 0 results and no error, which is exactly the wrong answer you described.
ae1bf64 to
63011d6
Compare
| ```bash | ||
| ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b | ||
| ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --yes | ||
| ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --repo ~/work/my-project | ||
| ``` |
There was a problem hiding this comment.
Caught, and rewritten. It was describing the first version of this PR.
What was wrong: it still showed 20250601-100000-abc123 in the examples, it still carried a "Why not the Viewer" section arguing a case @iredmail has since accepted, and it did not mention the global lookup at all, which is the largest behaviour change in the PR. The tests section listed names that no longer exist.
It now describes what the command actually does, and the review-attention section credits where each of the three safety changes came from: the DeleteSessionAt boundary to @NanaseInori, --repo failing closed to you, and filesystem-identity comparison to @Qiyuanqiii. I checked every test name in it against the tree rather than writing them from memory.
You were right to point at it. I had been keeping the code and the docs current through each round and letting the description rot, which is the one part a reviewer reads first.
| 命令会列出候选项并且不删除任何内容;用 `--repo` 指定其中一个。 | ||
|
|
||
| 命令会打印该会话的仓库、分支、开始时间、文件数和评论数,并请你确认。**非交互式的 | ||
| stdin 一律视为「否」**,因此管道或 CI 任务需要传入 `--yes`(`-y`)来跳过确认。 |
There was a problem hiding this comment.
Pipelines corresponds to "流水线" in Chinese.
"管道" means pipes, not pipelines. These are different things.
| stdin 一律视为「否」**,因此管道或 CI 任务需要传入 `--yes`(`-y`)来跳过确认。 | |
| stdin 一律视为「否」**,因此流水线或 CI 任务需要传入 `--yes`(`-y`)来跳过确认。 |
There was a problem hiding this comment.
After this is done, I think we are ready to merge. I’d like a co-author too.
There was a problem hiding this comment.
Taken verbatim, pushed.
You are right and the distinction matters here. 管道 is the shell mechanism, 流水线 is the CI sense, and this sentence is about CI jobs rather than about |. I had translated the English word rather than the meaning.
Worth noting for whoever touches this page next: the rest of the file uses 管道 in several places where it genuinely does mean a shell pipe, for example the --color row and the stdout-is-not-a-terminal note, so those are correct as they stand. Line 111 and line 250 already use 流水线 in the CI sense. My line was the odd one out.
There was a problem hiding this comment.
Yes, and you have earned it rather than asked for a favour. The Chinese fix is in, so that is done.
Your findings are in the shipped behaviour, not just the wording:
--repofailing open was yours.CheckSessionRepofell through toreturn nilwhen a session recorded nocwd, so--repo Acould remove B's session through a colliding directory. That is nowErrSessionUnverifiableFindSessionsByIDswallowing every stat failure was yours, so a permission error reported as "saved nowhere" on a delete command- dropping the
--reporequirement entirely was your framing. The first version made the caller stand in the right repository, and you were right that the storage layout is not the caller's problem - the comment cleanup, the docs saying how rather than why, the stale description, and 管道 versus 流水线
Which email address do you want on the trailer? It only attaches to your account if the address is one GitHub already knows, and a trailer whose email it does not recognise renders as plain text and credits nobody, so I would rather you gave me the one you want than guess an id. The usual form:
Co-authored-by: wu21-web <YOUR-ID+wu21-web@users.noreply.github.com>
Reply with it and I will amend. @Qiyuanqiii is already on the commit for the cross-repository work; only the message changes, so the tree stays identical and nothing needs re-reviewing.
@NanaseInori's findings are in here too, but @Qiyuanqiii has said that is their bot account and the repository rules bar a bot from co-authorship, so I have left that alone.
There was a problem hiding this comment.
Co-authored-by: wu21-web <wu2196674@icloud.com>
There was a problem hiding this comment.
Added, pushed. GitHub resolves both trailers to real accounts, which I checked rather than assumed:
basil-k-aji-dev -> basil-k-aji-dev
Qiyuanqiii -> Qiyuanqiii
wu21-web -> wu21-web
The tree hash is unchanged (33d9601), so only the message moved and nothing needs re-reviewing.
One thing worth mentioning since it nearly went wrong: my first amend left a blank line between the two trailers, which breaks the block, because git only parses trailers as a contiguous final paragraph. git log --format='%(trailers:key=Co-authored-by)' returned only one of you. They are adjacent now and it returns both.
63011d6 to
b7852a0
Compare
af9266a to
42b054e
Compare
| return want, nil | ||
| } | ||
|
|
||
| // ListAllSessionIDs returns every saved session id, newest first. |
There was a problem hiding this comment.
| // ListAllSessionIDs returns every saved session id, newest first. | |
| // ListAllSessionIDs returns every saved session id. |
This is quite outdated.
There was a problem hiding this comment.
Taken verbatim, and you found more than a stale comment. The sort underneath it was making the same false claim:
sort.Slice(found, func(i, j int) bool { return found[i].SessionID > found[j].SessionID })Descending string order gives newest-first only while ids start with a timestamp. generateUUID produces a v4, so the leading bytes are random and that ordering is chronologically meaningless. The comment was not lagging behind the code; both were written for the id format this repository no longer uses.
The claim is gone from the comment, and the sort is ascending now so the order is deterministic without pretending to mean anything.
Genuine newest-first is possible if you want it, since this feeds shell completion and most people want their recent sessions first. It needs entry.Info() per file to read mtime, which is a stat per session on every tab-completion. Happy to do it if you think the ordering earns that; I did not want to add syscalls to a completion path on my own judgement.
Sessions accumulate in ~/.opencodereview/sessions/ with no way to remove one short of deleting files by hand. The Viewer stays read-only by design, so deletion belongs in the CLI, where the caller already owns the files. The session id is enough on its own: the command looks for it under every repository, so it works from anywhere. That a session is stored under a directory derived from its repository is an implementation detail, and making the caller stand in the right repository to delete by id pushes that detail onto them. --repo narrows the search rather than enabling it. An id saved for more than one repository lists the candidates and deletes nothing. encodeRepoPath replaces separators with "-", so two repository paths can share one sessions directory and one id can name two different files; choosing between them would be a coin flip on a file that cannot be recovered. The command prints the session's repository, branch, start time, file count and comment count in the same format `ocr session list` uses, then asks to confirm. A non-interactive stdin answers no, so `--yes` has to be passed deliberately from a pipeline or CI. Reports an unreadable session rather than folding it into "corrupt" -- a session whose metadata cannot be parsed is still deletable, since that is the record most likely to need removing. Documented in all five locales of the CLI reference. Co-authored-by: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Co-authored-by: wu21-web <wu2196674@icloud.com>
42b054e to
1fe7048
Compare
* fix(scan): keep NUL-delimited pathnames from git ls-files intact gitLs asks git for NUL-delimited paths and then applies TrimSpace to every record. Leading and trailing spaces are filename bytes, not record framing: " normal.go" became "normal.go", the later Lstat found nothing, and the tracked file left the scan with a warning and no error. A full scan reports success having reviewed one file fewer than the repository holds. The same function also took stdout and stderr combined on the branch every real scan uses, while a comment on the other branch explained why it must not. With -z there is no line structure to resynchronise on, so a warning that git writes while still exiting 0 -- an unreadable directory, for instance -- is glued to the front of the first pathname rather than arriving as a stray record, and that file is renamed out of the scan too. Fixes #1493 * test(scan): skip the stderr fixture where directory modes are not enforced os.Geteuid cannot identify root on Windows, where it returns -1, and a filesystem mounted without permission support ignores the mode outright. In both cases git reads the directory happily and writes no warning, so the test asserted against a stream that had nothing wrong with it. Reading the directory back answers that for every platform at once. Found by @Qiyuanqiii on #1490, where the same assumption failed on Windows CI.
Closes #1468
Adds
ocr session rmto delete one persisted session from~/.opencodereview/sessions/.ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --yes ocr session rm 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b --repo ~/work/my-project@iredmail asked for a delete button on the Viewer's session list. @Qiyuanqiii declined that on the grounds that the Viewer's read-only property is a tested invariant, and @iredmail has since accepted the shell command as the resolution.
Behaviour
The id is enough on its own. The command searches every repository, so it runs from any directory.
--reponarrows the search rather than enabling it. This came from @wu21-web and @Qiyuanqiii on this PR; the first version required--repoor the right working directory, which pushed an implementation detail of the storage layout onto the caller.An ambiguous id is listed, not guessed at.
encodeRepoPathreplaces separators with-, so two repository paths can share one sessions directory. Where an id resolves to more than one file, the candidates are printed and nothing is deleted.The summary is printed before the prompt, in the same format
ocr session listuses, so the answer is given against the branch, start time and file count rather than an opaque id.A non-interactive stdin answers no. A pipeline or CI job sees EOF immediately, so those callers pass
--yesdeliberately.A corrupt session is still deletable, since that is the record most likely to want removing. One that cannot be read is reported instead.
Where review attention is worth spending
The destructive primitive enforces its own boundary.
DeleteSessionAtrebuilds the target from validated parts rather than trusting theLocationit is handed, and refuses aLocationwhosePathdisagrees with its own fields. Found by @NanaseInori: the earlier version checked only the basename, and aLocationwith../..inEncodedDirdeleted a file two directories above the sessions root and returnednil.--repofails closed. A session recording a different repository is refused, and so is one recording none at all. Found by @wu21-web: the check previously fell through toreturn nilwhen nocwdwas recorded, so--repo Acould remove B's session through a colliding directory. Such a session stays deletable by id alone, which needs no attribution because the id located the file.Repository identity is asked of the filesystem.
sameRepoPathcompares cleaned paths and then falls back toos.SameFile. Found by @Qiyuanqiii on Windows:filepath.Cleanpreserves case, soD:andd:compared unequal whilea-bandA-Bmust stay distinct on a case-sensitive filesystem. Lowercasing would fix one and break the other.SessionFilePathis unchanged. It checks only that an id is non-empty, which is a failed open on the read path and would be an arbitrary file removal on this one.ValidateSessionIDcovers the delete path; tighteningSessionFilePathis a separate change with its own blast radius, since resume depends on it.Tests
Every test here was run against the revision it guards and observed to fail. The ones worth naming:
TestDeleteSessionAt_RefusesPathOutsideSessionsRootandTestDeleteSessionAt_RefusesATraversingEncodedDir— the second deletes a real file outside the sessions root against the old codeTestSessionRm_RepoFlagRefusesAnUnverifiableSessionandTestSessionRm_UnverifiableSessionIsStillDeletableByIDAlone— the fail-closed pairTestSessionRm_ListsCandidatesRatherThanGuessing— making the ambiguous branch pick the first match fails itTestFindSessionsByID_ReportsAStatFailureRatherThanSkippingIt— a permission error used to report as "saved nowhere"TestSessionRm_NonInteractiveStdinDoesNotDelete— makingconfirmDeletionreturn true fails itTestCLIReferenceDocumentsSessionRm— deleting thekosection alone fails itDocumentation
ocr session rmis documented in all five locales of the CLI reference, with the flags table and the non-interactive-stdin rule. The sections were rewritten after @wu21-web pointed out they explained why the command is designed as it is rather than how to use it, and the Korean section was retranslated after @Qiyuanqiii found it had never received that rewrite.Not verified
I have only run this on Linux. @Qiyuanqiii has retested on Windows across several revisions, including the directory-symlink case through
C:\Users\All Users, but the symlink test that creates a new link skips there for want of the privilege.Korean, Japanese, Russian and Chinese are not languages I can check. @Qiyuanqiii has corrected the Korean once already; corrections to any of the four are welcome and I will take them verbatim.
AI/LLM disclosure
Written with AI assistance (Claude Code, model Claude Opus 5). I reviewed every line, every behaviour claimed above is backed by a test run against the unpatched revision first, and the reasoning in this description and in the review threads is my own.