Skip to content

feat(session): add 'ocr session rm' to delete a saved session - #1490

Open
basil-k-aji-dev wants to merge 1 commit into
alibaba:mainfrom
basil-k-aji-dev:feat/session-rm
Open

basil-k-aji-dev wants to merge 1 commit into
alibaba:mainfrom
basil-k-aji-dev:feat/session-rm

Conversation

@basil-k-aji-dev

@basil-k-aji-dev basil-k-aji-dev commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Closes #1468

Adds ocr session rm to 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. --repo narrows the search rather than enabling it. This came from @wu21-web and @Qiyuanqiii on this PR; the first version required --repo or the right working directory, which pushed an implementation detail of the storage layout onto the caller.

An ambiguous id is listed, not guessed at. encodeRepoPath replaces 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 list uses, 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 --yes deliberately.

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. DeleteSessionAt rebuilds the target from validated parts rather than trusting the Location it is handed, and refuses a Location whose Path disagrees with its own fields. Found by @NanaseInori: the earlier version checked only the basename, and a Location with ../.. in EncodedDir deleted a file two directories above the sessions root and returned nil.

--repo fails 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 to return nil when no cwd was recorded, so --repo A could 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. sameRepoPath compares cleaned paths and then falls back to os.SameFile. Found by @Qiyuanqiii on Windows: filepath.Clean preserves case, so D: and d: compared unequal while a-b and A-B must stay distinct on a case-sensitive filesystem. Lowercasing would fix one and break the other.

SessionFilePath is 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. ValidateSessionID covers the delete path; tightening SessionFilePath is 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_RefusesPathOutsideSessionsRoot and TestDeleteSessionAt_RefusesATraversingEncodedDir — the second deletes a real file outside the sessions root against the old code
  • TestSessionRm_RepoFlagRefusesAnUnverifiableSession and TestSessionRm_UnverifiableSessionIsStillDeletableByIDAlone — the fail-closed pair
  • TestSessionRm_ListsCandidatesRatherThanGuessing — making the ambiguous branch pick the first match fails it
  • TestFindSessionsByID_ReportsAStatFailureRatherThanSkippingIt — a permission error used to report as "saved nowhere"
  • TestSessionRm_NonInteractiveStdinDoesNotDelete — making confirmDeletion return true fails it
  • TestCLIReferenceDocumentsSessionRm — deleting the ko section alone fails it

Documentation

ocr session rm is 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.

@github-actions

Copy link
Copy Markdown
Contributor

OpenCodeReview: Review complete: 0 finding(s) across 2 selected item(s).

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

@NanaseInori review

@NanaseInori NanaseInori left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

There are some issues, and I’ve already reproduced them on my local machine.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@NanaseInori all three were right. Pushed in fc9fad0.

Validation ordering. This was the real one. LoadSummary ran before DeleteSession validated, and since SessionFilePath checks only that the id is non-empty, a traversal-shaped id was resolved and read against before the delete refused it. The delete was protected; the read in front of it was not, which makes the protection partly decorative.

The id is now validated at the top of runSessionRm, before the working directory is even resolved:

if err := session.ValidateSessionID(sessionID); err != nil {
    return err
}

validateSessionID is exported as ValidateSessionID rather than reimplemented in the CLI, so the two paths cannot drift — which was your suggestion and is better than what I would have done.

The corrupt-session branch was unreachable, and worse than unreachable. You were right that LoadSummary tolerates malformed records. walkSessionFile does if err := json.Unmarshal(line, &rec); err == nil { ... }, so a damaged file returns a zero-value Summary with no error. The (its metadata could not be read) branch never ran, and the prompt presented the zeros as facts:

Delete session 20250601-100000-corrupt?
  branch:
  started: 0001-01-01T00:00:00Z
  files:   0 reviewed

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 {} did not exercise it — the new test writes genuinely malformed lines.

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:

--- FAIL: TestSessionRm_RejectsTraversalIDsBeforeTouchingTheFilesystem/../escape
    runSessionRm("../escape") reported a missing session; an invalid id must be refused as invalid

--- FAIL: TestSessionRm_CorruptSessionIsDeletableAndDescribedHonestly
    zero-value metadata was printed as if real

go build ./..., go vet, and the cmd/opencodereview and internal/session packages all pass.

I have left SessionFilePath itself unchanged. Tightening it would make the read path refuse ids it currently resolves, and resume depends on it, so it wants its own change rather than riding along here.

@Qiyuanqiii Qiyuanqiii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread internal/session/delete.go Outdated
Comment on lines +56 to +60
dir, err := SessionsDir(repoDir)
if err != nil {
return err
}
path := filepath.Join(dir, sessionID+".jsonl")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@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 2b5800e.

I confirmed the collision first:

/tmp/a-b/c  ->  tmp-a-b-c
/tmp/a/b-c  ->  tmp-a-b-c

Two such repositories have always seen each other's sessions in ocr session list. That is confusing and harmless. ocr session rm --repo A deleting B's file is neither, and the difference is entirely my doing.

DeleteSession now reads the working directory the session recorded and refuses when it names a different repository. In your case B's session records B's cwd, so deleting it from A returns ErrSessionOtherRepo and removes nothing.

Your warning about Summary.RepoDir was the part that would have caught me out. I would have compared that field. It is seeded with the caller's own repo at list.go:159 and only overwritten when a cwd record exists at :228, so a session that never recorded one compares equal to whatever repository asks — the check would have passed for exactly the records least able to prove ownership, and I would have shipped something that looked protective and was not. The cwd is now read from the file, and "no cwd recorded" is reported as unproven rather than as a match.

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:

session belongs to a different repository: 20250601-100000-bbbbbb was recorded for /tmp/a/b-c

Two repository paths can share one session directory, so this session is
visible here but belongs elsewhere. Delete it from the repository that
recorded it.

I have not fixed the encoding. Making encodeRepoPath injective would change where sessions are stored and needs a migration, so it wants its own issue rather than riding along in a delete command. The listing collision therefore remains — it is back to being a display quirk rather than a route to deleting someone else's data.

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:

--- FAIL: TestDeleteSession_RefusesASessionRecordedForAnotherRepo
    delete_test.go:157: want ErrSessionOtherRepo, got <nil>

go build ./..., go vet, and the internal/session and cmd/opencodereview packages all pass.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

Thanks for the Windows repro — that is precisely the shape 2b5800e now refuses.

Your synthetic session has cwd set to B, which is what makes it catchable: DeleteSession reads that record and compares it against the repository it was asked to act on, so running it against A returns ErrSessionOtherRepo and removes nothing. Your ocr session list --repo A output still shows B's session, because I have not touched the listing — that collision is unchanged and remains a display quirk.

The test I added builds the same two paths (a-b/c and a/b-c), asserts they still collide, writes a session recording B's cwd, and checks that deleting from A is refused while deleting from B works. It skips rather than passes silently if the encoding is ever fixed and the paths stop colliding.

One platform caveat I should state: I have only run this on Linux. sameRepoPath compares with filepath.Clean, so the recorded cwd and the caller's repo are normalised by the same OS-specific rules on whichever platform is running — but I have not exercised the Windows separator handling you used, and your report is the second time today Windows has come up on my changes.

Comment thread internal/session/delete.go Outdated
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@Qiyuanqiii thank you for retesting on Windows — that regression was mine and I could not have found it here. Fixed in 32b4875.

You are right that this cannot come from string comparison. filepath.Clean preserves case, so on a case-insensitive filesystem D:\repo and d:\repo are one directory spelled two ways and compared unequal. Lowercasing both sides would fix that and break the property you asked for in the same sentence, since a-b and A-B are genuinely different repositories on a case-sensitive filesystem.

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)
}

os.SameFile answers it correctly on every platform and also sees through a symlink or any other route to the same directory. The cleaned strings are still compared first, so the ordinary case costs nothing.

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:

--- FAIL: TestSameRepoPath_OneDirectorySpelledTwoWays/reached_through_a_symlink
    a symlink to the same directory must compare equal

The case-variation subtest skips on this machine, and says so, because Linux is case-sensitive and A-B genuinely is a different directory here:

--- SKIP: TestSameRepoPath_OneDirectorySpelledTwoWays/case_variation
    case-sensitive filesystem: A-B and a-b are different directories here

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 os.SameFile path — but it is a proxy, and your Windows run is the real check. This is the third time today a Windows detail on my work has only been catchable by you.

go build ./..., go vet and both packages pass.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

Looks good on Windows now. I retested 32b4875: both D: / d: and a-b / A-B work, and the original cross-repo deletion is still refused with B's file left intact. Deleting from B works too.

make test passed for the session, CLI and Viewer packages. The case-variation test ran and passed here; the symlink test was skipped because this machine lacks the required permission.

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.

@Qiyuanqiii Qiyuanqiii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@iredmail

iredmail commented Sep 20, 2026

Copy link
Copy Markdown

Thanks for picking up this request.

I don't mind a ocr session rm command, but it's likely i will never use it, because: it's a lot easier to find the session file under ~/.opencodereview/sessions/<project>/ on console, then i just remove it with rm <session-id>.jsonl. ocr session rm <session-id> is too much work for this simple job.

The best option for me is a button on the session list page on viewer, don't bother with a shell command.

@basil-k-aji-dev

basil-k-aji-dev commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

@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 rm you already do.

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 — TestMux_HasNoWriteRoutes asserts DELETE on a session route answers 405 — and that breaking it for one button is disproportionate. I agree with that reasoning and would not send a PR against it. But it does mean the original request is declined rather than solved, and @iredmail is right not to pretend otherwise.

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:

  • rm <session-id>.jsonl is unguarded. The cross-repo bug @Qiyuanqiii found is a property of the storage layout, not of my command — two repository paths can share one session directory, so a session listed under one repo may belong to another. Someone deleting by hand from ~/.opencodereview/sessions/<project>/ can hit exactly that, with nothing to stop them.
  • It makes deletion a supported operation with a defined contract rather than something users reverse-engineer from the directory layout.

Neither of those helps @iredmail, whose objection is about ergonomics, not safety.

One thing I could add if it is wanted. ocr session export already takes the session id as optional and defaults to the newest:

With no session id the newest session for the repo is exported.

rm could follow the same convention, so ocr session rm with no argument targets the newest session and still prompts. That does not close the gap with a button, and it removes the "find and type a UUID" part that @iredmail is objecting to. I have not implemented it, because it widens the PR and the PR may not be wanted at all.

@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.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

If he needs this feature, please open a separate PR. The final merge decision is up to lizhengfeng.

@lizhengfeng101 lizhengfeng101 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread internal/session/delete.go Outdated
Comment on lines +62 to +85
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One question: Do you have to be inside the session directory to be able to delete that session?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-project

That 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-project

But 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. Answering rm <id> alone means scanning every directory under ~/.opencodereview/sessions/.
  • That scan is the part I would not want to write into this PR. encodeRepoPath is not injective — /tmp/a-b/c and /tmp/a/b-c both encode to tmp-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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sure, viewer should be read only in my opinion.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@iredmail

Copy link
Copy Markdown

Kind reminder: don't over design. No one needs this ocr session rm so far.

@iredmail

Copy link
Copy Markdown

I suggest don't implement ocr session rm (yet) since no one asks for it so far.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@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 rm you already run.

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. encodeRepoPath replaces separators with -, so /tmp/a-b/c and /tmp/a/b-c both resolve to tmp-a-b-c and share one sessions directory. That means ocr session list can show you a session belonging to a different repository, and rm <session-id>.jsonl on it deletes the other project's session. That is true today, with or without this PR. If this closes, that collision is still worth an issue of its own.

@wu21-web

Copy link
Copy Markdown
Contributor

If so please remove that issue from development @basil-k-aji-dev

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@lizhengfeng101 all four are done, plus the two process points. The branch is now a single commit, 7ae8034.

1. CLI reference. Both the summary-table row and a ### ocr session rm section in all five locales, placed at the end of the ocr session block after export and following each file's existing shape, including the {#ocr-session-rm} anchor form ko uses. The row carries the delete/remove aliases, which I had left as . --yes and the non-interactive-stdin rule are both spelled out, since neither is discoverable by running the command.

I took the optional pin too. TestCLIReferenceDocumentsSessionRm sits beside the compare and export ones and checks the row, the section, the alias, the flag in use, the --yes flag row and the literal token stdin — that last one because each locale translates the rule into prose but keeps the token. I deleted the section header from ko alone to confirm it actually bites:

--- FAIL: TestCLIReferenceDocumentsSessionRm/ko
    ko/cli-reference.md: missing "### `ocr session rm`"

2. Ownership check before the prompt. You were right that this is the same wasted answer as a missing id, and printing RepoDir alone would only have made the refusal legible rather than avoiding it. The check is now an exported session.CheckSessionRepo, called before the prompt; DeleteSession calls the same function rather than repeating the rules, since this is the path where drift deletes a file. TestSessionRm_OtherRepoRefusedBeforePrompting asserts the prompt never appears.

3. Prompt formatting. It now uses describeStart, describeFiles, summary.RepoDir and summary.TotalComments — the same helpers ocr session list prints through, so the two cannot drift. TestSessionRm_PromptMatchesTheListFormatting pins that.

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 cannot read session %q: %w instead of being folded into "corrupt", so a permissions problem is not reported as damage. TestSessionRm_UnreadableSessionIsReportedNotTreatedAsCorrupt covers it.

Squashed. One feat commit instead of the feat + four fix series. You were right that the series read as the AI-generated-then-patched cycle the contributing rules warn about; the fixes were real review findings from @NanaseInori and @Qiyuanqiii rather than my own retries, but the history did not show that and squashing is the honest presentation either way.

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 Closes #1468 would auto-close @iredmail's Viewer-button request on merge while this ships a shell command instead. That is now Refs #1468 and the Development link is gone; #1468 stays open whatever happens here.

Unchanged since my last note: I have only run this on Linux. @Qiyuanqiii retested 32b4875 on Windows and the D:/d: and a-b/A-B cases pass there, but the symlink test skipped on their machine for want of the privilege, so that branch of sameRepoPath is unexercised on Windows.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

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.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

I'll rerun the Windows checks on 7ae8034, check whether I can exercise the symlink case here, and look into the failing unreadable-session test.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@Qiyuanqiii yes, and thank you for asking rather than letting it go — you should not have had to. Co-authored-by is the right one, not an acknowledgement line.

The whole cross-repository half of this PR is yours. You found that encodeRepoPath collapses /tmp/a-b/c and /tmp/a/b-c into one sessions directory, you showed it was data loss rather than a display quirk by deleting B's file from A, you reproduced it on Windows where I cannot test, and then you caught the regression my first fix introduced — filepath.Clean preserving case, so D: and d: compared unequal. sameRepoPath reaching for os.SameFile instead of comparing spellings exists because of that second report. You also told me which fallback to keep when a directory cannot be stat-ed, and I took your answer over the one I was leaning toward.

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:

Co-authored-by: Qiyuanqiii <YOUR-ID+Qiyuanqiii@users.noreply.github.com>

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 runSessionRm rather than just its wording. Say the word and the address, and I will add you.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

Just to clarify, @NanaseInori is my bot account. According to the repository rules, bots can’t be co-authors.

@Qiyuanqiii

Qiyuanqiii commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Retested the Windows path checks on 7ae8034. Drive-letter and directory-name case variations work, and cross-repo deletion is refused before the prompt.
I also tested an existing Windows directory symlink in both directions. Both passed. The test that creates a new symlink still skips because this machine lacks the required privilege.
This update covers those path-handling checks only.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

@Qiyuanqiii yes, and thank you for asking rather than letting it go — you should not have had to. is the right one, not an acknowledgement line.Co-authored-by

The whole cross-repository half of this PR is yours. You found that collapses and into one sessions directory, you showed it was data loss rather than a display quirk by deleting B's file from A, you reproduced it on Windows where I cannot test, and then you caught the regression my first fix introduced — preserving case, so and compared unequal. reaching for instead of comparing spellings exists because of that second report. You also told me which fallback to keep when a directory cannot be stat-ed, and I took your answer over the one I was leaning toward.encodeRepoPath``/tmp/a-b/c``/tmp/a/b-c``filepath.Clean``D:``d:``sameRepoPath``os.SameFile

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:

Co-authored-by: Qiyuanqiii <YOUR-ID+Qiyuanqiii@users.noreply.github.com>

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 rather than just its wording. Say the word and the address, and I will add you.runSessionRm

Sounds good. Please use this for the trailer:
Co-authored-by: Qiyuanqiii 267806965+Qiyuanqiii@users.noreply.github.com

Comment thread internal/session/delete.go Outdated
Comment on lines +80 to +83
if recorded, ok := recordedRepoDir(path); ok && !sameRepoPath(recorded, repoDir) {
return fmt.Errorf("%w: %s was recorded for %s", ErrSessionOtherRepo, sessionID, recorded)
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread cmd/opencodereview/session_cmd.go Outdated
Comment on lines +134 to +141
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.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is way too long.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread cmd/opencodereview/session_cmd.go Outdated
Comment on lines +142 to +144
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is outdated isn't it?

Suggested change
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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@wu21-web both of your review points are in, pushed.

The Long: help is four lines now instead of eight; the argument about why the Viewer stays read-only moved to the PR description, where it belongs.

The examples use a real id. You were right that 20250601-100000-abc123 was outdated: generateUUID is a v4, so ids look like 9f2c1b4a-7e35-4d61-b2f0-6c8a41d9e72b. Fixed in the help text and all five locales, and TestCLIReferenceDocumentsSessionRm pins the UUID form so it cannot drift back.

Also in from your earlier thread: --repo now fails closed when a session records no repository, with ErrSessionUnverifiable pointing at the --repo-less form rather than just refusing.

make check, make english-check and the full suite are green. Ready for another look whenever you are.

@wu21-web wu21-web left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the documentation. They were explaining why more than how to use those flags.

Comment on lines +505 to +506
Deletes one persisted session from `~/.opencodereview/sessions/`. The Viewer is
deliberately read-only, so deletion lives here, where the caller already owns

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the point of mentioning viewer here? This is our discussion for the development, you shouldn't have written it into the docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +517 to +520
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These are unnecessary explainations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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/` 中删除一个已持久化的会话。查看器被刻意设计为

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
`~/.opencodereview/sessions/` 中删除一个已持久化的会话。查看器被刻意设计为
`~/.opencodereview/sessions/` 中删除一个已保存的会话。查看器被刻意设计为

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken, and the sentence you were trimming is gone entirely along with the rest of the Viewer explanation.

Comment on lines +486 to +487
分隔符替换成 `-`,两个仓库路径可能共用同一个会话目录,在它们之间猜测等于对一个
无法恢复的文件掷硬币。`--repo` 把搜索范围缩小到一个仓库,从而解决这种情况。

@wu21-web wu21-web Sep 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please remove the story about the coin. This translation is not quite accurate. You should review translations yourself before submission.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 任务会

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please mention non-interactive system to be no by default before you introduce the yes flag because this is confusing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>` | 所有仓库 | 只在该仓库下查找这个会话。 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suggest adding a shorthand like -R in the design of gh api, since one command has its own flags. That is optional anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@Qiyuanqiii

Copy link
Copy Markdown
Contributor

@NanaseInori review

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@wu21-web all six points are in, pushed.

The docs section is about a third of its former length. Gone: the Viewer rationale, encodeRepoPath, the directory-collision reasoning and the coin flip. What is left says how to use the command. The stdin rule now comes before --yes rather than after it.

The one I did not do is -R, and I left my reasoning on that thread rather than skipping it quietly: all six session subcommands take --repo and none has a shorthand, so adding one here alone would make rm the odd one out. It is worth doing across the set and I will send that separately if you want it.

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 NanaseInori left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 shared completeSessionIDs, which only offers sessions for the current/--repo repository. A dedicated global completion for rm would better match the new command semantics.
  • If an old duplicate session ID is ambiguous and one recorded repository no longer exists, --repo may 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.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@NanaseInori you are right, and it is worse than "inconsistent with DeleteSession". Pushed as 090b556.

I reverted DeleteSessionAt to its old body and ran the new tests against it. One of them deletes a real file outside the sessions root:

--- FAIL: TestDeleteSessionAt_RefusesATraversingEncodedDir
    EncodedDir "../..": error = <nil>, want ErrSessionOutsideRoot
    a file outside the sessions root was deleted: stat .../important.jsonl: no such file or directory

So it was not only that the primitive stopped enforcing the boundary. A Location with ../.. in EncodedDir and a matching basename removed a file two directories above the sessions root and returned nil. Your point about caller provenance is exactly right, and the demonstration is stronger than the argument.

The fix takes your second suggestion rather than your first. Validating the string would have left Path load-bearing, so DeleteSessionAt now rebuilds the target from validated parts and treats Path as a claim to check rather than an instruction to follow:

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)
}

EncodedDir gets the same single-element rule the session id already has, since it is derived from a repository path and never supplied. What is removed is always <sessions root>/<encoded dir>/<session id>.jsonl, and a Location whose fields do not describe one file is reported instead of acted on.

TestDeleteSessionAt_RefusesPathOutsideSessionsRoot is there under the name you suggested, with three more: the traversing EncodedDir above, a Location whose Path disagrees with its own parts, and a control that a Location straight from FindSessionsByID still deletes.

Your first non-blocking note is in too. You were right that completeSessionIDs contradicted the command: it offers only the current repository's sessions, which hides exactly the ids the global lookup exists to reach. rm now uses completeSessionIDsAnywhere, which lists every repository's ids and shows the repository each one belongs to, and falls back to the scoped completion when --repo is given. Two tests cover both directions.

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, resolveWorkingDirForSession needs the path to exist, so --repo cannot select that historical entry. It is reachable: delete a project, keep its sessions, and have a colliding id. But every route I could see makes it worse somewhere else. Letting --repo accept a non-existent path would weaken the resolution every other session subcommand shares, and selecting by index would add a second way to name a session for a case that UUID-era ids make very rare. It deserves its own issue rather than a rushed answer inside this PR, and I will file one if you would like.

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 Qiyuanqiii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 encodeRepoPath collision 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 --yes has not actually been applied here. The Korean text still introduces --yes first and explains the non-interactive behavior afterwards.
  • The current English documentation includes the --repo ~/work/my-project usage 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.

Comment on lines +18 to +53
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And clean these AI code comments up. They took up almost half of the file. @basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You skipped the suggestion itself @basil-k-aji-dev

Use const when you can.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

@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. ko did not, my script printed success for it anyway, and I did not check the file afterwards. So your list is not a set of missed edits, it is the previous revision sitting untouched: the Viewer rationale, encodeRepoPath, the coin flip, --yes introduced before the stdin rule, no --repo example, and the long attribution paragraph. Every item you found was there because none of it was ever removed.

I took your advice and retranslated the current English section rather than editing the old prose. The particles are fixed too: id만, id가, --repo로, stdin은, --repo를, none of them spaced.

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:

en: new   zh: new   ja: new   ru: new   ko: new

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.

Comment on lines +16 to +25
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These are immutible.

Suggested change
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")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh I forgot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +158 to +160
info, statErr := os.Stat(path)
if statErr != nil || info.IsDir() {
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The current implementation skips every stat failure, not only ENOENT.

Suggested change
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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +507 to +511
```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
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@basil-k-aji-dev Look at your stale PR desc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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`)来跳过确认。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pipelines corresponds to "流水线" in Chinese.
"管道" means pipes, not pipelines. These are different things.

Suggested change
stdin 一律视为「否」**因此管道或 CI 任务需要传入 `--yes``-y`)来跳过确认。
stdin 一律视为「否」**因此流水线或 CI 任务需要传入 `--yes``-y`)来跳过确认。

@wu21-web wu21-web Sep 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After this is done, I think we are ready to merge. I’d like a co-author too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

  • --repo failing open was yours. CheckSessionRepo fell through to return nil when a session recorded no cwd, so --repo A could remove B's session through a colliding directory. That is now ErrSessionUnverifiable
  • FindSessionsByID swallowing every stat failure was yours, so a permission error reported as "saved nowhere" on a delete command
  • dropping the --repo requirement 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.

@wu21-web wu21-web Sep 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Co-authored-by: wu21-web <wu2196674@icloud.com>

@basil-k-aji-dev

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@basil-k-aji-dev
basil-k-aji-dev force-pushed the feat/session-rm branch 2 times, most recently from af9266a to 42b054e Compare September 21, 2026 06:13
Comment thread internal/session/delete.go Outdated
return want, nil
}

// ListAllSessionIDs returns every saved session id, newest first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// ListAllSessionIDs returns every saved session id, newest first.
// ListAllSessionIDs returns every saved session id.

This is quite outdated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
lizhengfeng101 pushed a commit that referenced this pull request Sep 21, 2026
* 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.
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.

Viewer feature request: Able to delete session on session list page

6 participants