Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Sapling SCM is a cross-platform, highly scalable, Git-compatible source control system. It consists of three main components:

- **Sapling CLI** (`eden/scm/`): The `sl` command-line tool, based on Mercurial. Written in Python and Rust.
- **Interactive Smartlog (ISL)** (`addons/`): Web-based and VS Code UI for repository visualization. React/TypeScript.
- **Mononoke** (`eden/mononoke/`): Server-side distributed source control server (Rust). Not yet publicly supported.
- **EdenFS** (`eden/fs/`): Virtual filesystem for large checkouts (C++). Not yet publicly supported.

## Build Commands

### Sapling CLI (primary development)

From `eden/scm/`:

```bash
make oss # Build OSS version for local usage (creates ./sl binary)
make install-oss # Install CLI to $PREFIX/bin
make local # Build for inplace usage
make clean # Remove build artifacts
```

### Running Tests

```bash
# All tests (from eden/scm/)
make tests

# Single test
make test-foo # Runs test-foo.t

# Using getdeps (full CI-style testing)
./build/fbcode_builder/getdeps.py test --allow-system-packages --src-dir=. sapling

# Single test with getdeps
./build/fbcode_builder/getdeps.py test --allow-system-packages --src-dir=. sapling --retry 0 --filter test-foo.t
```

### ISL (Interactive Smartlog)

From `addons/`:

```bash
yarn install # Install dependencies
yarn dev # Start dev server for ISL
```

From `addons/isl/`:

```bash
yarn start # Start Vite dev server
yarn build # Production build
yarn test # Run Jest tests
yarn eslint # Lint TypeScript/React code
```

### Website

From `website/`:

```bash
yarn start # Start Docusaurus dev server
yarn build # Build static site
yarn lint # Run ESLint and Stylelint
yarn format:diff # Check formatting with Prettier
yarn typecheck # TypeScript type checking
```

## Architecture

### Sapling CLI (`eden/scm/`)

- `sapling/` - Python CLI modules and commands
- `lib/` - Rust libraries (117+ modules): dag, indexedlog, gitcompat, checkout, clone, etc.
- `exec/` - Rust executables: hgmain, scm_daemon
- `tests/` - `.t` test files (1000+)

### ISL (`addons/`)

Yarn workspace with:
- `isl/` - React/Vite web UI
- `isl-server/` - Node.js backend
- `vscode/` - VS Code extension
- `shared/` - Shared utilities
- `components/` - Reusable UI components

## Code Style

- 2 spaces indentation
- 80 character line length
- Rust: 2024 edition, rustfmt configured in `.rustfmt.toml`
- TypeScript/JavaScript: ESLint + Prettier
- Python: Flake8

## Dependencies

- Python 3.8+
- Rust (stable)
- CMake 3.8+
- OpenSSL
- Node.js + Yarn 1.22 (for ISL/addons)

## Git Workflow

- Branch from `main`
- Developed internally at Meta, exported to GitHub
- CLA required for contributions (https://code.facebook.com/cla)
1 change: 1 addition & 0 deletions CLAUDE.md
1 change: 1 addition & 0 deletions addons/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export enum KeyCode {
R = 82,
S = 83,
T = 84,
Comma = 188,
Period = 190,
QuestionMark = 191,
SingleQuote = 222,
Expand Down
1 change: 1 addition & 0 deletions addons/isl-server/src/analytics/eventNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type TrackEventName =
| 'PrSubmitOperation'
| 'PullOperation'
| 'PullRevOperation'
| 'PullStackOperation'
| 'PurgeOperation'
| 'RebaseWarningTimeout'
| 'RebaseKeepOperation'
Expand Down
18 changes: 14 additions & 4 deletions addons/isl-server/src/github/githubCodeReviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
YourPullRequestsQuery,
YourPullRequestsWithoutMergeQueueQuery,
} from './generated/graphql';
import {parseStackInfo, type StackEntry} from './parseStackInfo';
import queryGraphQL from './queryGraphQL';

export type GitHubDiffSummary = {
Expand All @@ -60,6 +61,8 @@ export type GitHubDiffSummary = {
head: Hash;
/** Name of the branch on GitHub, which should match the local bookmark */
branchName?: string;
/** Stack info parsed from PR body Sapling footer. Top-to-bottom order (first = top of stack). */
stackInfo?: StackEntry[];
};

const DEFAULT_GH_FETCH_TIMEOUT = 60_000; // 1 minute
Expand Down Expand Up @@ -111,11 +114,14 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
private fetchYourPullRequestsGraphQL(
includeMergeQueue: boolean,
): Promise<YourPullRequestsQueryData | undefined> {
// Calculate date 30 days ago for the updated filter
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const dateFilter = thirtyDaysAgo.toISOString().split('T')[0];

const variables = {
// TODO: somehow base this query on the list of DiffIds
// This is not very easy with github's graphql API, which doesn't allow more than 5 "OR"s in a search query.
// But if we used one-query-per-diff we would reach rate limiting too quickly.
searchQuery: `repo:${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo} is:pr author:@me`,
// Fetch all open PRs in the repo updated in the last 30 days
searchQuery: `repo:${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo} is:pr is:open updated:>=${dateFilter}`,
numToFetch: 50,
};
if (includeMergeQueue) {
Expand Down Expand Up @@ -151,6 +157,9 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
this.logger.warn(`PR #${id} is missing base or head ref, skipping.`);
continue;
}
// Parse stack info from the PR body (Sapling footer format)
const stackInfo = parseStackInfo(summary.body) ?? undefined;

map.set(id, {
type: 'github',
title: summary.title,
Expand All @@ -174,6 +183,7 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
base: summary.baseRef.target.oid,
head: summary.headRef.target.oid,
branchName: summary.headRef.name,
stackInfo,
});
}
}
Expand Down
115 changes: 115 additions & 0 deletions addons/isl-server/src/github/parseStackInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Represents a single entry in a PR stack.
*/
export type StackEntry = {
/** True if this is the current PR (marked with arrow in footer) */
isCurrent: boolean;
/** The PR number */
prNumber: number;
};

/**
* The Sapling footer marker that indicates the start of stack info.
*/
const SAPLING_FOOTER_MARKER = '[//]: # (BEGIN SAPLING FOOTER)';

/**
* Legacy marker for backward compatibility.
*/
const LEGACY_STACK_MARKER = 'Stack created with [Sapling]';

/**
* Regex to match stack entries in the PR body.
* Matches lines like:
* * #123
* * #123 (2 commits)
* * __->__ #42
* * __->__ #42 (3 commits)
*/
const STACK_ENTRY_REGEX = /^\* (__->__ )?#(\d+).*$/;

/**
* Parse stack info from PR body. Matches the Sapling footer format:
*
* Stack ordering (top-to-bottom as it appears in the PR body):
* - First entry = top of stack (newest commits)
* - Last entry = closest to trunk (oldest commits)
*
* Example footer:
* ```
* ---
* [//]: # (BEGIN SAPLING FOOTER)
* Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](...).
* * #125
* * __->__ #124 ← current PR (marked with arrow)
* * #123
* * #122 ← closest to trunk (bottom of stack)
* ```
*
* @param body The PR body text
* @returns Array of stack entries in same order (top-to-bottom), or null if no stack info found
*/
export function parseStackInfo(body: string): StackEntry[] | null {
if (!body) {
return null;
}

const lines = body.split(/\r?\n/);
let inStackList = false;
const stackEntries: StackEntry[] = [];

for (const line of lines) {
if (lineHasStackListMarker(line)) {
inStackList = true;
continue;
}

if (inStackList) {
const match = STACK_ENTRY_REGEX.exec(line);
if (match) {
const [, arrow, number] = match;
stackEntries.push({
isCurrent: Boolean(arrow),
prNumber: parseInt(number, 10),
});
} else if (stackEntries.length > 0) {
// We've reached the end of the list (non-matching line after entries)
break;
}
}
}

return stackEntries.length > 0 ? stackEntries : null;
}

/**
* Check if a line indicates the start of the stack list.
*/
function lineHasStackListMarker(line: string): boolean {
return line === SAPLING_FOOTER_MARKER || line.startsWith(LEGACY_STACK_MARKER);
}

/**
* Get the index of the current PR in the stack.
* @param stackEntries The parsed stack entries
* @returns The index of the current PR, or -1 if not found
*/
export function getCurrentPrIndex(stackEntries: StackEntry[]): number {
return stackEntries.findIndex(entry => entry.isCurrent);
}

/**
* Get all PR numbers in the stack.
* @param stackEntries The parsed stack entries
* @returns Array of PR numbers in stack order (top-to-bottom)
*/
export function getStackPrNumbers(stackEntries: StackEntry[]): number[] {
return stackEntries.map(entry => entry.prNumber);
}
14 changes: 14 additions & 0 deletions addons/isl/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {availableCwds, CwdSelections} from './CwdSelector';
import {Drawers} from './Drawers';
import {EmptyState} from './EmptyState';
import {useCommand} from './ISLShortcuts';
import {PRDashboard} from './PRDashboard';
import {Internal} from './Internal';
import {TopBar} from './TopBar';
import {TopLevelAlerts} from './TopLevelAlert';
Expand Down Expand Up @@ -95,9 +96,22 @@ function ISLDrawers() {
right: {...state.right, collapsed: !state.right.collapsed},
}));
});
useCommand('ToggleLeftSidebar', () => {
setDrawerState(state => ({
...state,
left: {...state.left, collapsed: !state.left.collapsed},
}));
});

return (
<Drawers
leftLabel={
<>
<Icon icon="git-pull-request" />
<T>PR Stacks</T>
</>
}
left={<PRDashboard />}
rightLabel={
<>
<Icon icon="edit" />
Expand Down
2 changes: 2 additions & 0 deletions addons/isl/src/ISLShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const CMD = isMac ? Modifier.CMD : Modifier.CTRL;
export const [ISLCommandContext, useCommand, dispatchCommand, allCommands] = makeCommandDispatcher({
OpenShortcutHelp: [Modifier.SHIFT, KeyCode.QuestionMark],
ToggleSidebar: [CMD, KeyCode.Period],
ToggleLeftSidebar: [CMD, KeyCode.Comma],
OpenUncommittedChangesComparisonView: [CMD, KeyCode.SingleQuote],
OpenHeadChangesComparisonView: [[CMD, Modifier.SHIFT], KeyCode.SingleQuote],
Escape: [Modifier.NONE, KeyCode.Escape],
Expand Down Expand Up @@ -58,6 +59,7 @@ export const ISLShortcutLabels: Partial<Record<ISLCommandName, string>> = {
Escape: t('Dismiss Tooltips and Popups'),
OpenShortcutHelp: t('Open Shortcut Help'),
ToggleSidebar: t('Toggle Commit Info Sidebar'),
ToggleLeftSidebar: t('Toggle PR Stacks Sidebar'),
OpenUncommittedChangesComparisonView: t('Open Uncommitted Changes Comparison View'),
OpenHeadChangesComparisonView: t('Open Head Changes Comparison View'),
SelectAllCommits: t('Select All Commits'),
Expand Down
Loading