feat: add Sapling SCM support#295
Conversation
Pre-release binaries for testingBuilt from commit
Binaries are attached as assets on the v0.0.0-sapling-rc1 pre-release. Quick install# macOS (Apple Silicon)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc1/crit-darwin-arm64 -o crit && chmod +x crit
# Linux (x86_64)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc1/crit-linux-amd64 -o crit && chmod +x critOr build from source: go install github.com/tomasz-tomczyk/crit@feat/sapling-scm-support |
I think it will be best if you land this diff first, unless you want to fix the detection problem yourself. |
80e2009 to
6eec910
Compare
Updated: rc2 binaries availableRebased on latest main ( v0.0.0-sapling-rc2 pre-release # macOS (Apple Silicon)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc2/crit-darwin-arm64 -o crit && chmod +x crit
# macOS (Intel)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc2/crit-darwin-amd64 -o crit && chmod +x crit
# Linux (x86_64)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc2/crit-linux-amd64 -o crit && chmod +x crit
# Linux (ARM64)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc2/crit-linux-arm64 -o crit && chmod +x critOr build from source: @omry — would love to hear how it works for you! If auto-detection isn't picking up your Sapling repo, |
Sure! Happy for you to fork off of this branch? Or you can share the diff and I can have Claude figure it out 😅
We do have Is this something we should revisit with #300 do you think? GitHub just added stacked PRs so I wanted to see if it's relevant for git implementation too. |
Hehe, sure.
not quiet sure if this is the thing that bugs me (or that my solution is really the right one):
I think it's not related to the sapling support. we can discuss it separately.
They did!? This is definitely making #300 much more relevant though and we can discuss it there in light of the upcoming GitHub support. |
|
Here is the diff (this is from sl so not exactry a git diff). changeset: 8e73ba3660362c99f616a30e4e38fa416738d403 (@)
parent: 0ef8be177cc6b20db673dab6289450fc257c2ee5
user: Omry Yadan <omry@yadan.net>
date: Sat, 18 Apr 2026 21:31:52 +0800
fix: detect Sapling repos that store metadata under .git/sl
Recognize .git/sl as Sapling metadata during VCS auto-detection so mixed
Git/Sapling repositories select the Sapling backend by default.
Also include the detected VCS in `crit status` output to make backend
selection easier to debug.
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
/crit
/crit.exe
# Review session files (generated at runtime)
*.comments.json
*.review.md
diff --git a/main.go b/main.go
--- a/main.go
+++ b/main.go
@@ -2021,8 +2021,10 @@
os.Exit(1)
}
+ vcsName := ""
branch := ""
if vcs := DetectVCS(""); vcs != nil {
+ vcsName = vcs.Name()
branch = vcs.CurrentBranch()
}
@@ -2050,15 +2052,16 @@
}
if jsonOutput {
- printStatusJSON(branch, revPath, revExists, matchedSession)
+ printStatusJSON(vcsName, branch, revPath, revExists, matchedSession)
return
}
- printStatusHuman(branch, revPath, revExists, matchedSession)
+ printStatusHuman(vcsName, branch, revPath, revExists, matchedSession)
}
-func printStatusJSON(branch, revPath string, revExists bool, session *sessionEntry) {
+func printStatusJSON(vcsName, branch, revPath string, revExists bool, session *sessionEntry) {
result := map[string]interface{}{
+ "vcs": vcsName,
"branch": branch,
"review_file": revPath,
"review_file_exists": revExists,
@@ -2096,7 +2099,10 @@
}
}
-func printStatusHuman(branch, revPath string, revExists bool, session *sessionEntry) {
+func printStatusHuman(vcsName, branch, revPath string, revExists bool, session *sessionEntry) {
+ if vcsName != "" {
+ fmt.Printf("VCS: %s\n", vcsName)
+ }
if branch != "" {
fmt.Printf("Branch: %s\n", branch)
}
diff --git a/sapling_test.go b/sapling_test.go
--- a/sapling_test.go
+++ b/sapling_test.go
@@ -1,6 +1,10 @@
package main
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
// Compile-time interface compliance check.
var _ VCS = &SaplingVCS{}
@@ -185,3 +189,31 @@
}
}
}
+
+func TestHasSLDirFrom_DetectsDotSL(t *testing.T) {
+ root := t.TempDir()
+ child := filepath.Join(root, "nested", "repo")
+ if err := os.MkdirAll(filepath.Join(root, ".sl"), 0o755); err != nil {
+ t.Fatalf("mkdir .sl: %v", err)
+ }
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("mkdir child: %v", err)
+ }
+ if !hasSLDirFrom(child) {
+ t.Fatal("expected hasSLDirFrom to detect .sl metadata")
+ }
+}
+
+func TestHasSLDirFrom_DetectsDotGitSL(t *testing.T) {
+ root := t.TempDir()
+ child := filepath.Join(root, "nested", "repo")
+ if err := os.MkdirAll(filepath.Join(root, ".git", "sl"), 0o755); err != nil {
+ t.Fatalf("mkdir .git/sl: %v", err)
+ }
+ if err := os.MkdirAll(child, 0o755); err != nil {
+ t.Fatalf("mkdir child: %v", err)
+ }
+ if !hasSLDirFrom(child) {
+ t.Fatal("expected hasSLDirFrom to detect .git/sl metadata")
+ }
+}
diff --git a/vcs.go b/vcs.go
--- a/vcs.go
+++ b/vcs.go
@@ -102,7 +102,8 @@
return &SaplingVCS{}
}
- // Auto-detect: check for .sl/ first since Sapling repos on top of git have both.
+ // Auto-detect: check for Sapling metadata first since Sapling repos on top of
+ // git can have both backends available.
if hasSLDir() {
return &SaplingVCS{}
}
@@ -114,16 +115,24 @@
return nil
}
-// hasSLDir checks whether a .sl/ directory exists at or above the current directory.
+// hasSLDir checks whether Sapling metadata exists at or above the current
+// directory. Sapling repos may store metadata either in .sl/ or in .git/sl/.
func hasSLDir() bool {
dir, err := os.Getwd()
if err != nil {
return false
}
+ return hasSLDirFrom(dir)
+}
+
+func hasSLDirFrom(dir string) bool {
for {
if info, err := os.Stat(filepath.Join(dir, ".sl")); err == nil && info.IsDir() {
return true
}
+ if info, err := os.Stat(filepath.Join(dir, ".git", "sl")); err == nil && info.IsDir() {
+ return true
+ }
parent := filepath.Dir(dir)
if parent == dir {
break
|
|
One thing I noticed: Maybe worth some output when detecting a git repo as sl repo ( |
If I understand what you're saying right, yes this is intended. In git terms, when on a featue branch, all changes from that branch will be shown. There are toggles in the header to switch back to just Not 100% sure how this translates to Sapling though and might need some UX adaptation.
Yes. Can switch in the header to just show unstaged. |
rc3 — improved detection + config support@omry thanks for the diff! Applied your changes with one modification to the What changedYour diff (applied):
Modified behavior for
The reason: running New: You can now set your preferred VCS in {
"vcs": "sl"
}This is a preference, not a hard requirement — if Precedence: Binaries# macOS (Apple Silicon)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc3/crit-darwin-arm64 -o crit && chmod +x crit
# macOS (Intel)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc3/crit-darwin-amd64 -o crit && chmod +x crit
# Linux (x86_64)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc3/crit-linux-amd64 -o crit && chmod +x crit
# Linux (ARM64)
curl -L https://github.com/tomasz-tomczyk/crit/releases/download/v0.0.0-sapling-rc3/crit-linux-arm64 -o crit && chmod +x crit |
ah, it looks like it's always disabled in sapling: I suggest the following borrowing terminology from sl status: Modified should include M, A, R and !. (diff content via
|
| // Check if Sapling metadata exists under .git/sl — this means sl was used | ||
| // here but it's primarily a git repo. Hint but don't switch automatically. | ||
| if hasGitSLDir() { | ||
| fmt.Fprintf(os.Stderr, "Hint: Sapling detected. Use --vcs sl or set \"vcs\": \"sl\" in config to use Sapling.\n") |
There was a problem hiding this comment.
I suggest hint and behavior:
This Git Repository has Sapling metadata.
- Use --vcs sl to override Crit to use Sapling
- Set \"vcs\": \"sl\" to always use Sapling and suppress this hint
- Set \"vcs\": \"git\" to always use Git and suppress this hint
| AuthUserName string `json:"auth_user_name,omitempty"` | ||
| AuthUserEmail string `json:"auth_user_email,omitempty"` | ||
| CleanupOnApprove *bool `json:"cleanup_on_approve,omitempty"` | ||
| VCS string `json:"vcs,omitempty"` // preferred VCS backend: "git", "sl" |
There was a problem hiding this comment.
I think this should not be a preference.
If a user tells you to use Sapling for a repo and you can't find it, falling back to git is wrong. it will just make things worse.
Error out saying Sapling binary sl is not found in the path.
Right, so if I was to add commit picker to |
Base commit picker would be nice, but I think the minimum for sl support is to allow me to select modified (aka dirty) files. secondary: I think the current default is not useful (at least not for me, on sl). |
Parse sl status (Mercurial format) and sl diff --stat output into crit's FileChange and NumstatEntry types. (#288) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…#288) Phase 1 of Sapling SCM support: extract a VCS interface from existing git-specific code so multiple VCS backends can be supported. - vcs.go: VCS interface with all operations + DetectVCS() auto-detection - git_vcs.go: GitVCS struct delegating to existing git.go functions - git_vcs_test.go: interface compliance and basic method tests - session.go: VCS field on Session, NewSessionFromVCS constructor, VCSName in SessionInfo Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…r.go (#288) Replace direct IsGitRepo()/git.go function calls with DetectVCS() and VCS interface methods throughout the codebase: - main.go: add --vcs flag, use DetectVCS in createSession, runReview, resolveServerConfig, serveSessionKey, runStop, runStatus, runConfig - session.go: use VCS in NewSessionFromFiles, ChangeBaseBranch, EnsureFileEntry, GetCommits; resolveGitContext returns VCS - watch.go: use VCS for WorkingTreeFingerprint, RefreshDiffs, DiffNumstat - server.go: use VCS in handleBranches and handleFilesList - github.go: use DetectVCS in resolveReviewPath, resolveReviewPathFromDaemon - share.go: use DetectVCS in loadShareConfig - git.go: add .sl to skipDirs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full implementation of the VCS interface for Sapling. Uses sl CLI for all operations. Handles bookmark-based branching, lack of staging area, and Mercurial-format status output. Wire into DetectVCS. (#288)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Thread baseBranch through serverConfig to VCS.SetDefaultBranchOverride()
- Route scoped queries (scopedHunks, availableScopes, GetSessionInfoScoped,
loadScopedFileState, computeScopedDiffHunks) through VCS interface
- Pass VCS to ensureLoaded and populateEagerFile for lazy/eager file diffs
- Add DetectVCS("git") test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add "vcs" field to config (e.g. "vcs": "sl" in .crit.config.json) - Precedence: --vcs flag > config vcs > auto-detect - Config VCS falls back gracefully if backend unavailable (sl not in PATH) - Detect .git/sl as hint (not auto-select) — prints suggestion to use --vcs sl - Show VCS name in crit status output (human + JSON) - Extract hasSLDirFrom() for testability Co-authored-by: Omry Yadan <omry@yadan.net> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Route FileContentAtRef through VCS interface (was direct git bypass), use configured VCS for author fallback, guard empty baseRef, use --change for initial commit support, add vcs to config docs/README. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b257361 to
4374a7c
Compare
gofmt formatting, nilerr nolint for intentional graceful degradation, gocyclo nolint for ChangeBaseBranch (inherent rollback complexity). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the gate-and-abort push flow with a partition-and-export model.
Today, a single stale or unanchored comment aborts the entire push; now
crit push splits comments into three buckets at submit time:
- postable -> pushed to GitHub via gh review API as before
- full_stack -> kept local, written to ~/.crit/exports/<pr>-<ts>.md
- unmapped -> kept local (empty HeadSHA in range mode, or stale
HeadSHA), written to the same export file
Postable comments still flow through createGHReview unchanged. The
orphan buckets get a markdown dump grouped by reason ("Full-stack-only",
"Stale or unanchored") so the user has a paste-able record. A one-line
summary on stdout reports both halves: "Posted N comments. M comments
exported to <path>."
The --dry-run path now prints the bucket plan ("Push plan for PR #295:
12 postable, 2 full-stack, 1 stale.") plus per-bucket detail with
truncated comment bodies.
UX changes
- The "Switch to Layer diff before posting a platform review" abort is
gone. Full-stack comments are silently bucketed instead.
- A single stale comment no longer kills the whole push.
- gh failures only exit 1 when there are no orphans to fall back on;
if we wrote an export file, exit 0 (something useful happened).
Removed helpers: applyPushGates, filterCommentsForPush,
commentPassesPushGates, displayPushDryRun. Kept: resolveCurrentPRHead,
parsePushFlags, postPushReplies. Added: bucketCommentsForPush,
renderOrphanMarkdown, writeOrphanExport, summarizeBuckets,
detailedDryRun, bucketsToGHComments, exportsDir.
Tests
- New push_buckets_test.go covering pure layer, mixed scope, stale,
no-anchor, working-tree, resolved-skipped, GitHub-anchored-skipped,
markdown rendering, export file creation, summary formatting,
dry-run sectioning, GH comment shaping, deterministic ordering.
- Converted the four old TestApplyPushGates_* tests in
focus_session_test.go to bucket assertions covering the same
semantic concerns (full-stack diversion, stale head, no anchor,
working-tree legacy filter).
- Scrubbed remaining 'plannotator' references that survived in
spec/plan markdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the old focus-picker popover (Working tree / Your stack / Other PRs / Branches) in favour of a narrower, more focused UI inspired by stacked-PR review tools that only handle in-stack navigation. Frontend - New stack breadcrumb in the header: main > #294 > #295 (reviewing) > #296, rendered inline (not a popover). Default-branch click flips diff_scope to full_stack on the current focus; other entries swap focus via /api/focus. Visibility uses stack.length > 1 (not is_stacked) so local-only stacks and Sapling-style commit chains also get the breadcrumb. Long stacks collapse the middle into an ellipsis. - New Working tree pill, visible whenever focus is range in git mode. - Layer/full-stack toggle now appears whenever default_sha is resolved, not only for is_stacked focus. - Other PRs / Remote branches sections deleted from the frontend; backend /api/picker still returns them but the frontend stops consuming them. - focus-changed SSE handler fixed to parse the wrapper.content payload correctly (the old handler relied on page reloads in tests to mask this). Backend (picker.go) - detectStack adds tier-3 fallback for naked-commit ancestors on the topic chain (commits reachable from HEAD but not the default branch). Uses first-line commit subject as the label so Sapling-style git stacks of unbranched commits surface in the picker. - Topic-chain filter prevents long-lived feature branches from drowning the breadcrumb in default-branch ancestors. CLI - Help text now lists --pr <num|url> and --range <baseSHA>..<headSHA> as the entry points into range mode. Tests - Rewrote focus-picker.rangemode.spec.ts and focus-picker-wt-entry for the breadcrumb. Added breadcrumb-current-marker, breadcrumb-default-branch, breadcrumb-ellipsis, breadcrumb-local-stack, breadcrumb-no-pr specs. - Updated focus-switch.rangemode for the working-tree pill. - loading.filemode + nogit.nogit assert the breadcrumb and pill stay hidden in non-VCS modes. - range-loading header-label test now checks the breadcrumb reviewing marker instead of the removed #focusPickerLabel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Summary
VCSinterface from git-specific code, implementGitVCS(zero behavior change for git users)SaplingVCSbackend usingslCLI for all operations.sl/directory) with--vcsflag overrideCloses #288
Known limitations
--vcsflag: Only applies to the serve command, notcrit stop/crit status(tracked as TODO)Test plan
critin a Sapling repo detects and opens review UIcrit --vcs gitforces git backend in colocated repos