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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,23 @@ go install github.com/flexdinesh/servediff/cmd/servediff@main

## Usage

Watch the selected checkout in one foreground process:
Capture the selected checkout once in one foreground process:

```sh
servediff
servediff .
servediff /path/to/repo --host 0.0.0.0
servediff . --replace
servediff . --base HEAD --no-browser
```

Collection and ingestion run in-process. The same process serves the web UI,
REST and MCP until Ctrl-C. It watches only the selected checkout, follows branch
switches, and preserves observations/comments across restarts in the shared local
database. The browser follows new observations; choosing a historical review or
starting a comment pins the current review. Failed collection never substitutes
an empty diff.
REST and MCP until Ctrl-C. The path defaults to the current directory and resolves
to its containing checkout, including linked worktrees. Only that checkout is
collected. The browser opens the captured snapshot; later edits and branch switches
do not change it. Run the command again to capture a newer diff. Observations and
comments persist across restarts in the shared local database. Failed collection
never substitutes an empty diff.

Only one local instance runs. Starting another prompts before replacing it;
scripts must pass `--replace`. Replacement requests authenticated graceful
Expand Down Expand Up @@ -89,7 +91,7 @@ The default listener binds to `127.0.0.1` on an available port from 7981–7990.
reviews to reachable clients. The browser opens a loopback URL for wildcard binds.

Legacy `review`, `serve`, and `service` commands remain compatibility
paths during migration. Bare `servediff` without redirected stdin prints help.
paths during migration. Use `servediff --help` for usage.

## Agent hooks and remote ingestion

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/FileExplorerNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function FileExplorerNav() {
? "Connecting…"
: "Disconnected"
: refreshEnabled
? "Watching changes"
? "Refresh available"
: "Fixed snapshot";
return (
<aside
Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/app-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ import {
type Dispatch,
type SetStateAction,
} from "react";
import {
capabilityEnabled,
usePinContext,
useSession,
} from "./session-context.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
import { useSidebarState } from "./sidebar-context.tsx";
import { useDiff } from "./use-diff.ts";
import { useFileNavigation } from "./use-file-navigation.ts";
Expand Down Expand Up @@ -59,10 +55,6 @@ function WorkspaceProvider({ children }: { children: ReactNode }) {
const scopes = capabilities.diff.scopes.values;
const [mode, setMode] = useState<DiffMode>("all");
const [draft, setDraft] = useState<ReviewComment | null>(null);
const pinContext = usePinContext();
useEffect(() => {
if (draft) pinContext();
}, [draft, pinContext]);
const [collapsed, setCollapsed] = useState(new Set<string>());
const diff = useDiff(contextId, mode);
const repository = diff.repository;
Expand Down
34 changes: 4 additions & 30 deletions apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,27 +161,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {
setContexts(entries);
setCatalogLoaded(true);
setCatalogError("");
const query = new URLSearchParams(window.location.search);
const checkout = query.get("watch");
const source = query.get("source");
const watching = checkout !== null && source !== null;
const first = watching
? entries.find(
(entry) =>
entry.observation?.checkoutKey === checkout &&
entry.observation.sourceId === source &&
entry.availability === "available",
)
: entries.find(contextHasChanges);
if (
first &&
(watching || !contextFromUrl()) &&
first.id !== contextFromUrl()
) {
const first = entries.find(contextHasChanges);
if (first && !contextFromUrl() && first.id !== contextFromUrl()) {
window.history.replaceState(
null,
"",
`/contexts/${encodeURIComponent(first.id)}${watching ? window.location.search : ""}`,
`/contexts/${encodeURIComponent(first.id)}`,
);
setSelectedId(first.id);
}
Expand Down Expand Up @@ -290,7 +275,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
</h1>
<p>
{empty
? "Run servediff . to watch a checkout, or servediff sync to publish remotely."
? "Run servediff to review a checkout, or servediff sync to publish remotely."
: state.status === "error"
? state.detail
: catalogError || "Reading review context…"}
Expand Down Expand Up @@ -329,17 +314,6 @@ export function useSession() {
return session;
}

export function usePinContext() {
const catalog = useContext(CatalogContext);
if (!catalog) throw new Error("Context pinning requires SessionProvider");
const { select, selectedId } = catalog;
return useCallback(() => {
if (new URLSearchParams(window.location.search).has("watch")) {
select(selectedId);
}
}, [select, selectedId]);
}

export function useDeleteContext() {
const catalog = useContext(CatalogContext);
if (!catalog) throw new Error("Context deletion requires SessionProvider");
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/use-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export function useReview(
},
});
setSaveError(null);
setFeedback("Draft open — automatic refresh paused.");
setFeedback("Draft open.");
},
[enabled, setDraft],
);
Expand Down Expand Up @@ -327,7 +327,7 @@ export function useReview(
}
setDraft({ ...comment });
setSaveError(null);
setFeedback("Draft open — automatic refresh paused.");
setFeedback("Draft open.");
return true;
},
[setDraft],
Expand Down
31 changes: 21 additions & 10 deletions apps/web/test/browser/local-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,21 @@ async function ready(page: Page, value = "all-v1") {
).toBeVisible();
}

test("watch follows this checkout across clean and branch transitions while historical URLs stay pinned", async ({
test("snapshot stays selected after newer captures and branch transitions", async ({
page,
}) => {
const state = await localWorkspace(page);
const original: ApiContext = {
...state.context,
id: "original",
kind: "observation",
capabilities: {
...state.context.capabilities,
diff: {
...state.context.capabilities.diff,
refresh: { state: "unavailable" },
},
},
observation: {
sourceId: "machine",
hostname: "host",
Expand Down Expand Up @@ -197,8 +204,10 @@ test("watch follows this checkout across clean and branch transitions while hist
const context = contexts.find((entry) => entry.id === id);
return route.fulfill({ json: context });
});
await page.goto("/?watch=checkout&source=machine");
await expect(page).toHaveURL(/\/contexts\/original\?watch=/);
await page.goto("/contexts/original");
await ready(page);
await expect(page.locator("#connection")).toHaveText("Fixed snapshot");
await expect(page.locator("#refresh")).toBeHidden();
contexts.unshift({
...original,
id: "clean-other-branch",
Expand All @@ -208,7 +217,7 @@ test("watch follows this checkout across clean and branch transitions while hist
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect(page).toHaveURL(/\/contexts\/clean-other-branch\?watch=/);
await expect(page).toHaveURL(/\/contexts\/original$/);
await ready(page);
await page
.getByRole("button", {
Expand All @@ -218,18 +227,18 @@ test("watch follows this checkout across clean and branch transitions while hist
.click();
const draft = page.getByRole("textbox", { name: "Review comment" }).first();
await draft.fill("Keep this unsaved review");
await expect(page).toHaveURL(/\/contexts\/clean-other-branch$/);
await expect(page).toHaveURL(/\/contexts\/original$/);
contexts.unshift({ ...original, id: "later-change" });
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect(draft).toHaveValue("Keep this unsaved review");
await expect(page).toHaveURL(/\/contexts\/clean-other-branch$/);
await page.goto("/contexts/original");
await expect(page).toHaveURL(/\/contexts\/original$/);
await page.goto("/contexts/clean-other-branch?watch=checkout&source=machine");
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect(page).toHaveURL(/\/contexts\/original$/);
await expect(page).toHaveURL(/\/contexts\/clean-other-branch\?watch=/);
});

async function deletableWorkspace(
Expand Down Expand Up @@ -688,7 +697,9 @@ test("context switching isolates delayed repository responses and fixed captures
await expect(page.locator("#changes-title")).toHaveText("Piped");
});

test("empty service explains local watch and remote sync", async ({ page }) => {
test("empty service explains local review and remote sync", async ({
page,
}) => {
await page.route("**/api/v2/contexts?*", (route) =>
route.fulfill({ json: { contexts: [], nextCursor: null } }),
);
Expand All @@ -698,7 +709,7 @@ test("empty service explains local watch and remote sync", async ({ page }) => {
).toBeVisible();
await expect(
page.getByText(
"Run servediff . to watch a checkout, or servediff sync to publish remotely.",
"Run servediff to review a checkout, or servediff sync to publish remotely.",
),
).toBeVisible();
});
Expand Down
12 changes: 7 additions & 5 deletions cmd/servediff/ingestion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,24 @@ func cliRepository(t *testing.T) string {
return root
}

func TestBareInvocationShowsHelpWithoutConsumingStdin(t *testing.T) {
func TestBareInvocationRejectsNonRepositoryWithoutConsumingStdin(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("SERVEDIFF_CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
stdin, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
defer stdin.Close()
var output bytes.Buffer
if err := run(t.Context(), nil, stdin, &output, io.Discard); err != nil {
t.Fatal(err)
if err := run(t.Context(), nil, stdin, &output, io.Discard); err == nil {
t.Fatal("accepted non-Git current directory")
}
position, err := stdin.Seek(0, io.SeekCurrent)
if err != nil || position != 0 {
t.Fatalf("stdin consumed: %d %v", position, err)
}
if !strings.Contains(output.String(), "servediff PATH") || !strings.Contains(output.String(), "git diff | servediff") || strings.Contains(output.String(), "servediff pipe") {
t.Fatalf("help: %s", output.String())
if output.Len() != 0 {
t.Fatalf("unexpected startup: %s", output.String())
}
}

Expand Down
3 changes: 0 additions & 3 deletions cmd/servediff/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,6 @@ func acquireInput(values options, stdin *os.File) (daemon.InitialInput, error) {
}
return daemon.InitialInput{Kind: "capture", Raw: raw, SubmittedFrom: cwd}, nil
}
if !values.repositorySet {
return daemon.InitialInput{}, errors.New("provide a repository path, a fixture, or pipe a Git diff")
}
path, err := filepath.Abs(values.directory)
return daemon.InitialInput{Kind: "worktree", Path: path}, err
}
Expand Down
38 changes: 9 additions & 29 deletions cmd/servediff/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ import (
"strings"
"time"

"github.com/flexdinesh/servediff/internal/collector"
"github.com/flexdinesh/servediff/internal/contextservice"
"github.com/flexdinesh/servediff/internal/daemon"
"github.com/flexdinesh/servediff/internal/ingestion"
"github.com/mattn/go-isatty"
)

Expand All @@ -42,29 +40,14 @@ func runLocal(ctx context.Context, arguments []string, stdin *os.File, stdout, s
return err
}
started := time.Now()
options, err := collectionOptions(values)
if err != nil {
return err
}
var request ingestion.Request
if input.Kind == "worktree" {
request, err = collector.Collect(ctx, input.Path, options)
} else {
request, err = collector.CollectPatch(ctx, string(input.Raw), input.SubmittedFrom, options)
}
request, err := collectInitialInput(ctx, values, input)
if err != nil {
return err
}
input.Ingestion = &request
label := "Piped diff"
if input.Kind == "worktree" {
input.Path, label = request.Metadata.Root, request.Metadata.Root
input.Watch = func(ctx context.Context, service *contextservice.Service) error {
return collector.Watch(ctx, request.Metadata.Root, options, collector.Fingerprint(request), time.Second, func(ctx context.Context, next ingestion.Request) error {
_, err := service.Ingest(ctx, next)
return err
}, func(err error) { fmt.Fprintf(stderr, "Watch: %v\n", err) })
}
}
return daemon.RunForeground(ctx, settings, input, func(status daemon.Status) (bool, error) {
if values.replace {
Expand Down Expand Up @@ -93,16 +76,16 @@ func runLocal(ctx context.Context, arguments []string, stdin *os.File, stdout, s
return strings.EqualFold(strings.TrimSpace(reply.answer), "y") || strings.EqualFold(strings.TrimSpace(reply.answer), "yes"), nil
}
}, func(status daemon.Status, submitted *contextservice.Submission) {
if input.Kind == "capture" && submitted != nil {
address := status.BrowserURL + "/contexts/" + url.PathEscape(submitted.Context.ID)
loaded := submissionInput(*submitted, time.Since(started))
loaded.mode, loaded.directory, loaded.contextID, loaded.mcpURL = "pipe", "", submitted.Context.ID, status.BrowserURL+"/mcp"
writeStartup(stdout, loaded, address)
openBrowser(values, address, stderr)
if submitted == nil {
return
}
address := status.BrowserURL + "/?" + url.Values{"watch": {request.Metadata.CheckoutKey}, "source": {request.Metadata.SourceID}}.Encode()
fmt.Fprintf(stdout, "Watching %s\n%s\nMCP: %s/mcp\n", input.Path, address, status.BrowserURL)
address := status.BrowserURL + "/contexts/" + url.PathEscape(submitted.Context.ID)
loaded := submissionInput(*submitted, time.Since(started))
if input.Kind == "capture" {
loaded.mode, loaded.directory = "pipe", ""
}
loaded.contextID, loaded.mcpURL = submitted.Context.ID, status.BrowserURL+"/mcp"
writeStartup(stdout, loaded, address)
openBrowser(values, address, stderr)
})
}
Expand All @@ -122,9 +105,6 @@ func acquireLocalInput(values options, stdin *os.File) (daemon.InitialInput, err
if piped || values.directory == "-" {
return acquireInput(values, stdin)
}
if !values.repositorySet {
return daemon.InitialInput{}, errors.New("provide a repository path or pipe a Git diff")
}
path, err := filepath.Abs(values.directory)
return daemon.InitialInput{Kind: "worktree", Path: path}, err
}
8 changes: 5 additions & 3 deletions cmd/servediff/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import (
"github.com/flexdinesh/servediff/internal/diffsource"
)

func TestLocalInputRequiresPathOrPatch(t *testing.T) {
func TestLocalInputDefaultsToCurrentDirectory(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
input, err := acquireLocalInput(options{directory: root, repositorySet: true}, nil)
if err != nil || input.Kind != "worktree" || input.Path != root {
t.Fatalf("path input: %+v %v", input, err)
}
if _, err := acquireLocalInput(options{directory: "."}, nil); err == nil {
t.Fatal("accepted missing path and stdin")
input, err = acquireLocalInput(options{directory: "."}, nil)
if err != nil || input.Kind != "worktree" || input.Path != root {
t.Fatalf("default input: %+v %v", input, err)
}
}

Expand Down
Loading
Loading