clonefind

package
v0.2.0-rc.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 24 Imported by: 0

README

Finding local clones

internal/clonefind finds the local git clones of the GitHub repositories a person picks. It searches only while a picked repository has no clone, and attaches the clone it finds; repositories that are not picked are never looked for. The setup and Repositories screens list GitHub repositories without local paths, and show a picked repository's search (running in the snapshot) and then its clone, or that none was found. It reads local files and runs read-only git commands. It makes no network request, and nothing about local folders leaves the computer.

Sources

  1. Folders agents worked in. The working directory of every agent hook event in the record (session.cwd, else the hook's dir; Git hooks are ignored), and of every Claude Code, Codex (root sessions only) and Cursor session store. The stores are found with collector.FindStores; one cheap probe per file reads the recorded working directory, never the transcript body. Only the newest 3000 stores are probed. Each directory keeps the time of its latest activity (an event's time, a store's modification time).
  2. Code folders. ~/code ~/src ~/dev ~/projects ~/workspace ~/repos ~/git ~/go/src/github.com, and ~/Documents (see below), those that exist, plus the parent directory of each clone found in 1 when it lies below the home directory.

Folders read and never read

Read: the agent session stores (~/.claude/projects, ~/.codex/sessions, ~/.codex/archived_sessions, Cursor's state.vscdb), the hook event record, the folders listed above, and a directory an agent worked in (to find the clone containing it). In a clone, git reads .git/config and the repository directory; no file of the working tree is read.

A walk goes at most 4 levels below its folder, stops at a directory with a .git entry, and never follows a symlink to a directory. It never enters hidden folders, node_modules, vendor, target, dist, build, __pycache__, Library or Applications. It never starts in, or below, Desktop, Downloads, Pictures, Movies, Music, Public, iCloud Drive, or a Google Drive, Dropbox or OneDrive folder (the lists are defined in internal/scanskip), after resolving symlinks, so a code folder that links there is skipped. It refuses the filesystem root, the home directory and anything above it as a starting point, and never reports any of them as a clone. A directory an agent worked in is allowed anywhere (the agent was there), but the folder around it is walked only if it is below the home directory and not one of those.

Documents

~/Documents is walked like the code folders on Linux and Windows. On macOS it is a protected folder: the first read makes the system ask the user, so the automatic scan leaves it out. Options.IncludeDocuments (GOOS is injectable for tests) or POST /api/clones/found/refresh with {"include_documents": true} runs one scan that includes it; the choice is not stored, and a later scan leaves Documents out again. A directory an agent worked in inside Documents is a candidate on Linux and Windows, since no walk is needed. Desktop and Downloads are never walked anywhere.

Protected folders on macOS

macOS asks the user before an app first reads ~/Documents, ~/Desktop, ~/Downloads, ~/Library/Mobile Documents (iCloud Drive) or ~/Library/CloudStorage. On macOS (Options.GOOS) an agent directory under any of them is dropped lexically, and again after resolving symlinks, before any stat or git command; only the one-off Documents opt-in lets ~/Documents through, and an event never carries it. Names are compared without regard to case on macOS and Windows (scanskip.DirFor, UserDataFor), and Windows AppData is never a walk folder or parent. Git may follow an include.path in a clone's configuration to a file elsewhere; baum does not control that.

On every system an agent directory is stat'ed with its parents up to the home directory, to find the clone containing it, and git then runs in that clone.

Matching

A clone is any working tree with a remote on github.com (also www. and ssh.github.com). Every such remote counts, so a fork (origin is the fork, upstream the picked repository) is listed under both names. An SSH alias is not resolved (no ssh -G), url.<base>.insteadOf rewrites are not applied, and Enterprise hosts are ignored. This textual match is deliberately simpler than the registry's: AddFor and AttachFound read the clone with github.RepositoryFromRemote after the user's insteadOf rewrites, which may run ssh -G to resolve an SSH alias (a local command, no network). A candidate the registry cannot match is simply not attached. Names are lowercase owner/name. Linked worktrees sharing a common directory collapse into one candidate: the main working tree (common directory <path>/.git) if found, else the most recently used. An agent worktree in a hidden folder (kept under the clone or the home directory in a hidden folder) stands for its main clone: when the common directory ends in /.git, the directory above it is probed as well (it must be a non-bare working tree whose top level is that directory, at a place a scan may read) and is the candidate. A directory whose git top level is neither it nor a parent of it (core.worktree) is dropped.

For each repository, candidates are listed best first: clones an agent worked in, most recent first, then clones only a walk found, by path. A candidate is {path, common_dir, last_activity, source}, source being agent or scan.

Limits

2000 directories probed with git (two commands each, 3 s per command), 50000 directories walked per run (all folders together; a folder inside one already walked is skipped), depth 4, 3000 session stores, and the run's context. A run that hits a limit reports what it found.

The service

Service (run by the daemon) keeps the last result in memory and attaches clones:

  • When it scans. Only while a picked repository has no available clone: at daemon start in the background (readiness does not wait for it), every hour, and when the event subscription missed events; after any registry write through the API, Trigger: it does nothing unless a picked repository has no clone registered (one registry query, no git), else attaches from the last result, and scans again unless the last scan was under a minute ago. From Trigger until that check ends or its scan does, the snapshot reports running, so a repository just picked reads as being looked for. A scan also runs when asked (POST /api/clones/found/refresh). With nothing picked the daemon reads no folder.
  • What it attaches. For each picked repository with no available clone it calls Registry.AttachFound(path, repository) with the best candidate, trying the next on failure (logged, never fatal). AttachFound files the clone under the picked repository when any remote names it, so a fork clone (origin the fork, upstream the picked repository) is attached to the upstream without registering or picking the fork. It never changes what is stored: the finder skips a working tree or repository directory already registered to any repository, and the registry refuses with ErrClonedElsewhere rather than move one (AddFor moves it), so a fork clone registered for the fork is not flipped to the upstream and back. It requires the repository to be picked in the same transaction (ErrNotPicked), so a repository unpicked meanwhile is not picked again. Unpicked repositories are never attached.
  • Events. It subscribes to agent hook events. The directory is marked seen first, so it is handled once. Then one registry query that runs no git asks for picked repositories with no clone registered; while there are none, an event costs that and nothing else. Otherwise the directory is probed, the question is asked again under the attach lock, and a clone of such a repository is attached at once (the main clone for an agent worktree).
  • Telling the app. After attaching it publishes the daemon's index change, and attached in the API lists the last 20 attachments. No notice event is published: those are the desktop app's notifications and each names a pull request.

HTTP

Mounted by the daemon in internal/uiserver behind the authenticated host and origin checks, since it returns local paths.

Route Result
GET /api/clones/found the last result
POST /api/clones/found/refresh {} or {"include_documents": true} starts a scan unless one runs; 202 {"started","running"}
{
  "scanned_at": "2026-07-01T10:00:00Z",
  "running": false,
  "repositories": {
    "owner/name": [
      {"path": "/work/code/name", "common_dir": "/work/code/name/.git",
       "last_activity": "2026-07-01T09:30:00Z", "source": "agent"}
    ]
  },
  "attached": [{"repository": "owner/name", "path": "...", "at": "..."}]
}

scanned_at is null before the first scan, last_activity null for a clone only a walk found.

Documentation

Overview

Package clonefind finds the local clones of GitHub repositories without asking: in the folders coding agents worked in and in the usual code folders. It reads local files and runs read-only git commands; it makes no network request, and nothing it learns leaves the computer.

Index

Constants

View Source
const (
	// SourceAgent is a folder a coding agent worked in, from a hook event or
	// a session store.
	SourceAgent = "agent"
	// SourceScan is a clone found only by walking a code folder.
	SourceScan = "scan"
)

Where a candidate was found.

View Source
const (
	DefaultMaxDepth     = 4
	DefaultMaxProbes    = 2000
	DefaultMaxWalked    = 50000
	DefaultProbeTimeout = 3 * time.Second
)

Default limits; see Options.

View Source
const (
	// Buffer is how many hook events wait for the service; the record drops
	// the ones a slow service misses and the service then scans again.
	Buffer = 64
	// DefaultInterval is how often the service scans while a picked
	// repository has no clone.
	DefaultInterval = time.Hour
)

Service timings and bounds.

Variables

View Source
var DefaultFolders = []string{"code", "src", "dev", "projects", "workspace", "repos", "git", filepath.Join("go", "src", "github.com")}

DefaultFolders are the code folders, relative to the home directory, that a scan walks when they exist.

Functions

func AgentDir

func AgentDir(e hookevents.Event) (string, bool)

AgentDir is the directory an agent hook event worked in: the agent's own working directory, else the hook's.

func MatchEvent

func MatchEvent(e hookevents.Event) bool

MatchEvent selects the hook events the service reads: those of coding agents.

Types

type Attached

type Attached struct {
	Repository string    `json:"repository"`
	Path       string    `json:"path"`
	At         time.Time `json:"at"`
}

Attached is a clone the service registered by itself.

type Candidate

type Candidate struct {
	// Path is the working tree's top-level directory, and CommonDir the
	// repository directory its linked worktrees share. Both have symlinks
	// resolved, as the registry stores them.
	Path      string
	CommonDir string
	// LastActivity is when an agent last worked in the clone (or one of its
	// worktrees); zero for a clone only a scan found.
	LastActivity time.Time
	Source       string
}

Candidate is one local clone of a repository.

type Events

type Events interface {
	Recent(limit int) ([]hookevents.Record, error)
}

Events is the record of hook events.

type Found

type Found struct {
	ScannedAt    *time.Time              `json:"scanned_at"`
	Running      bool                    `json:"running"`
	Repositories map[string][]FoundClone `json:"repositories"`
	Attached     []Attached              `json:"attached"`
}

Found is the JSON of GET /api/clones/found.

type FoundClone

type FoundClone struct {
	Path         string     `json:"path"`
	CommonDir    string     `json:"common_dir"`
	LastActivity *time.Time `json:"last_activity"`
	Source       string     `json:"source"`
}

FoundClone is one clone in Found.

type Match

type Match struct {
	Repository string
	Candidate  Candidate
}

Match is a clone found for one repository.

func Locate

func Locate(ctx context.Context, o Options, dir string, at time.Time) []Match

Locate resolves one directory an agent worked in to the repositories its clone's remotes name, or nothing when it is not in such a clone.

type Options

type Options struct {
	// Home is the user's home directory.
	Home string
	// Events is the hook event record; nil reads none.
	Events Events
	// Stores locates the agent session stores. Empty directories default to
	// the usual ones below Home.
	Stores collector.StoreOptions
	// Folders replaces DefaultFolders (relative to Home, or absolute).
	Folders []string
	// ExtraRoots are further folders to walk.
	ExtraRoots []string
	// GOOS is the operating system, runtime.GOOS by default. On darwin
	// ~/Documents is a protected folder whose first read prompts the user, so
	// it is walked only when IncludeDocuments is set; elsewhere it is walked
	// like the other code folders.
	GOOS             string
	IncludeDocuments bool
	Now              func() time.Time
	// Probe replaces the git probe, for tests.
	Probe Probe
	// MaxDepth is how deep a walk goes below its folder (default
	// DefaultMaxDepth), MaxProbes how many directories are probed with git
	// (DefaultMaxProbes), MaxWalked how many directories a run walks in all
	// folders together (DefaultMaxWalked) and ProbeTimeout the time each git command gets
	// (DefaultProbeTimeout).
	MaxDepth, MaxProbes, MaxWalked int
	ProbeTimeout                   time.Duration
}

Options configure Find. Only Home is required.

type Probe

type Probe func(ctx context.Context, dir string) (gitlocal.Light, error)

Probe resolves a directory to its working tree and remotes.

type Registry

type Registry interface {
	Repositories(context.Context) ([]repos.Repository, error)
	// PickedWithoutClones and CloneRows read the registry only, running no
	// git; Repositories probes every clone.
	PickedWithoutClones(context.Context) ([]string, error)
	CloneRows(context.Context) ([]repos.Clone, error)
	// AttachFound registers a clone for a repository that is still picked,
	// without moving a clone registered to another one.
	AttachFound(ctx context.Context, path, repository string) (repos.Clone, error)
}

Registry is the part of the repository registry the service uses.

type Result

type Result struct {
	ScannedAt time.Time
	// Repositories maps a lowercase owner/name to its clones, best first.
	Repositories map[string][]Candidate
}

Result is what one run found.

func Find

func Find(ctx context.Context, o Options) (Result, error)

Find locates local clones from agent activity and the code folders.

type Service

type Service struct {
	Registry Registry
	Options  Options
	// Changed tells the app the registry changed.
	Changed func()
	Log     *slog.Logger
	// Interval overrides DefaultInterval.
	Interval time.Duration
	// contains filtered or unexported fields
}

Service keeps the last Result in memory, scans in the background, and registers the clone it finds of a picked repository that has none.

func (*Service) Handler

func (s *Service) Handler() http.Handler

Handler serves /api/clones/found. It has no authentication: the daemon mounts it behind its authenticated host and origin checks, because the paths it returns are local data for the app alone.

func (*Service) Refresh

func (s *Service) Refresh() bool

Refresh starts a scan unless one is running, and reports whether it did.

func (*Service) RefreshWithDocuments

func (s *Service) RefreshWithDocuments() bool

RefreshWithDocuments is Refresh for one scan that also walks ~/Documents where it is otherwise left out (macOS). The choice is not kept.

func (*Service) Run

func (s *Service) Run(ctx context.Context, events <-chan hookevents.Record, dropped func() uint64)

Run scans while a picked repository has no clone: at start, when events were missed, and every Interval. Nothing is searched for repositories that are not picked; Trigger scans when one is. It attaches the clone of a picked repository an agent event shows. It returns when ctx ends.

func (*Service) Snapshot

func (s *Service) Snapshot() Found

Snapshot is the current state, as the API serves it.

func (*Service) Trigger

func (s *Service) Trigger()

Trigger is called when picked repositories may have changed: it attaches what the last scan found and, if a picked repository still has no clone, scans again unless it just did.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL