Documentation
¶
Overview ¶
Package git is agentsync's local-only destination-versioning helper. It wraps go-git to give each rendered destination agent dir (~/.claude, ~/.codex, …) its own git repo so a bad `agentsync apply` is a quick rollback away (issue #118).
These repos are a LOCAL ROLLBACK HISTORY and are NEVER pushed to a remote. The rendered files they version contain secrets resolved to cleartext at apply time; keeping the repos local-only is what makes that acceptable (the canonical ~/.agentsync/ source — the thing users commit and push — only ever holds secret references). Accordingly this package exposes NO remote/push surface at all: there is no function that adds a remote or pushes, and TestNoPushSurface guards that the surface never grows one. Do not add one.
Index ¶
- Constants
- Variables
- func HasNestedRepoBelow(dir string) (bool, error)
- func OwnsExactly(dir string) (bool, error)
- func Short(s string) string
- type Checkpoint
- type FileChange
- type Identity
- type Repo
- func (r *Repo) CommitStaged(message string, id Identity) (string, error)
- func (r *Repo) Dir() string
- func (r *Repo) EnsureDirSecure(warn func(string))
- func (r *Repo) HasMultipleCheckpoints() (bool, error)
- func (r *Repo) IsAncestorOfHead(hash string) (bool, error)
- func (r *Repo) IsClean() (bool, error)
- func (r *Repo) Log(n int) ([]Checkpoint, error)
- func (r *Repo) Plan(targetRev string) (targetHash string, changes []FileChange, err error)
- func (r *Repo) Resolve(rev string) (string, error)
- func (r *Repo) Restore(targetRev, message string, id Identity) (revertHash, snapshotHash string, err error)
- func (r *Repo) SnapshotDirtyTracked(message string, id Identity) (string, error)
- func (r *Repo) SnapshotPreApply(message string, id Identity, plannedRels []string) (string, error)
- func (r *Repo) Stage(relPaths []string) error
- func (r *Repo) StageTrackedDeletions() ([]string, error)
- func (r *Repo) UntrackedPaths() ([]string, error)
- type State
Constants ¶
const NoticeFile = "AGENTSYNC_LOCAL_HISTORY.md"
NoticeFile is written at the repo root on init and committed in the first checkpoint, so anyone browsing the history sees what it is and that it must not be pushed.
Variables ¶
var DefaultIdentity = Identity{Name: "agentsync", Email: "agentsync@localhost"}
DefaultIdentity is used when the config supplies no override.
Functions ¶
func HasNestedRepoBelow ¶
HasNestedRepoBelow reports whether any git repository exists STRICTLY below dir (a `.git` entry in a subdirectory). agentsync refuses to init a repo that would wrap another repo, and the revert/commit paths re-probe with it so a foreign repo cloned under an owned root AFTER init is caught before a destructive hard reset (the cross-run nesting hazard: a child dir was versioned — or a foreign repo appeared — before/after a parent-dir agent was enabled). Short-circuits on the first hit; a missing dir is reported as false.
COST (design note, issue #175): this is a full `filepath.WalkDir(dir, …)` that short-circuits only once it FINDS a nested `.git`. When a version root collapses to a busy IDE/app directory (see generic.versionRootOf's whole-IDE-dir tradeoff), the common case — NO nested repo — walks the entire, possibly large, subtree once per untracked-dir init. It is correct (it swallows per-entry walk errors and never fails the apply), just potentially slow: an unbounded one-time apply-tail cost. It is left unbounded deliberately — a depth cap would have to exceed however deep agentsync (or a user) might nest a managed dir, and mis-sizing it would silently weaken the nesting guard, a worse failure than a slow first init. Measured cost (BenchmarkHasNestedRepoBelow, in-container): ~10ms per walk over a 2,000-leaf-dir tree at depth 4 — the no-nested-repo worst case that cannot short-circuit.
Symlink-aware: `filepath.WalkDir` does not descend into symlinked directories, so a symlinked foreign repo below dir (e.g. ~/.claude/plugins -> /elsewhere/checkout) would otherwise be missed. When an entry is a symlink to a directory, this shallow- probes the link target for a `.git` entry (see nestedRepoViaSymlink). The probe does not recurse through the symlink, so symlink cycles cannot trap it.
func OwnsExactly ¶
OwnsExactly reports whether dir is itself the root of an agentsync-owned repo — an EXACT `.git` at dir carrying the marker — without the upward search Detect does. Use this where you must act on the repo AT dir (Open/commit/revert), since Detect's DetectDotGit can match a parent repo that merely contains dir.
func Short ¶ added in v0.11.0
Short abbreviates a hex hash string to the conventional 7-char form for display and diagnostics. A string already shorter than 7 chars is returned unchanged. A plumbing.Hash caller passes h.String(). This is the single hash-abbreviation helper for the package and the CLI (via agit.Short).
Types ¶
type Checkpoint ¶
type Checkpoint struct {
Hash string // full hash
Short string // first 7 chars of Hash
Subject string // first line of the commit message
When time.Time // author time
}
Checkpoint is one commit in a destination repo's history.
type FileChange ¶
type FileChange struct {
Path string
// Kind is "create" | "modify" | "delete" — the three merkletrie actions Plan
// maps. "unknown" is a defensive label for a future/unexpected go-git action
// that is neither insert nor delete nor modify (not produced today); like a
// modify it is applied by writing the target tree's content.
Kind string
}
FileChange describes one file a restore would touch, for `revert --dry-run`.
type Identity ¶
Identity is the author/committer recorded on checkpoint commits. agentsync uses a dedicated identity (not the user's git identity) so machine-authored checkpoints are distinct and committing works even when no global git identity is configured.
type Repo ¶
type Repo struct {
// contains filtered or unexported fields
}
Repo wraps a go-git repository plus its absolute work-tree root.
func Init ¶
Init creates a new agentsync-owned git repo at dir: PlainInit, write the local-only NOTICE, stamp the [agentsync] managed=true marker into .git/config (so Detect recognizes it as ours), and best-effort tighten the .git dir to 0o700 (the history may carry cleartext secrets). Callers MUST gate on Detect == StateUntracked first; Init errors if a repo already exists at dir.
ATOMIC-ish: the NOTICE is written BEFORE the marker is stamped, so Detect never reports StateAgentsyncOwned until the notice exists — a repo that Detects as owned ALWAYS carries the "do not push / may contain cleartext secrets" NOTICE. If any post-PlainInit step fails, the freshly-created .git is removed so Detect reverts to StateUntracked and the next apply retries cleanly, rather than leaving a marked-but-noticeless (or notice-less owned) cleartext-secret repo.
The .git chmod is BEST-EFFORT: a chmod failure (e.g. Windows) never fails Init and is routed through tightenGitDir. A silent init-time failure is NOT swallowed — it is re-checked and surfaced on every apply's commit path via EnsureDirSecure.
func Open ¶
Open opens an existing repo at dir (an exact .git at dir, not a nested parent). Callers gate on Detect first; Open does not re-check the marker.
func (*Repo) CommitStaged ¶
CommitStaged records one commit of the current index authored by id, or returns ("", nil) when the index has nothing to commit (so an apply that changed nothing produces no empty checkpoint).
func (*Repo) EnsureDirSecure ¶ added in v0.11.0
EnsureDirSecure re-asserts the at-rest control that makes agentsync's blessed local-only cleartext-secret history acceptable: the repo's `.git` dir must be 0o700. It is called on every apply's Open/commit path (and on fresh inits), so a history whose perms drifted looser — a `git gc`, a restore-from-tar without mode bits, an admin `chmod` — is best-effort re-tightened rather than left world/group-readable indefinitely while every apply writes fresh secret blobs into it. It also surfaces an init-time chmod that silently failed.
It is BEST-EFFORT and NON-RECURSIVE and never aborts the apply. All reporting goes through the injected warn callback (so internal/git stays free of ui.Printer); a nil warn silences it. On Windows it is a no-op. A warning fires exactly when the `.git` perms were not already 0o700 (whether or not the re-tighten then succeeds).
func (*Repo) HasMultipleCheckpoints ¶
HasMultipleCheckpoints reports whether the repo has at least two commits, so a default "undo the most recent apply" (HEAD~1) has somewhere to land.
func (*Repo) IsAncestorOfHead ¶ added in v0.11.0
IsAncestorOfHead reports whether the commit named by hash (a full hex hash, as returned by Resolve) is reachable from the repo's current HEAD — HEAD itself included. Resolve alone accepts ANY revision whose object exists in the store, including a commit from an orphaned/rewound lineage that is NOT one of this repo's checkpoints; restoring to such a commit would have undefined semantics for an append-only checkpoint history, so revert gates --to on this. Walks the full commit graph from HEAD (not just first parents), short-circuiting on a hit.
func (*Repo) IsClean ¶
IsClean reports whether the worktree has no uncommitted changes to TRACKED files (untracked files are ignored — agentsync never commits them).
func (*Repo) Log ¶
func (r *Repo) Log(n int) ([]Checkpoint, error)
Log returns up to n checkpoints, newest first. n <= 0 returns the full history.
func (*Repo) Plan ¶
func (r *Repo) Plan(targetRev string) (targetHash string, changes []FileChange, err error)
Plan computes what Restore(targetRev) would change in the worktree relative to the current HEAD, WITHOUT writing anything. Returns the resolved target hash and the file changes (sorted by path).
func (*Repo) Resolve ¶
Resolve turns a revision (e.g. "HEAD", "HEAD~1", a short or full hash) into a full commit hash, erroring clearly when it does not resolve.
func (*Repo) Restore ¶
func (r *Repo) Restore(targetRev, message string, id Identity) (revertHash, snapshotHash string, err error)
Restore makes the worktree's TRACKED files match the targetRev checkpoint and records the result as a NEW commit on top of the current HEAD. It is append-only: HEAD advances, nothing is rewritten or lost, so the bad apply stays in history and the revert is itself revertible. Returns the new commit hash plus the snapshot hash (below), or ("", snapshot, nil) when the worktree already matches target (no checkpoint recorded).
SAFE-BY-CONSTRUCTION for uncommitted TRACKED edits: Restore FIRST snapshots any uncommitted changes to already-tracked files into history (via SnapshotDirtyTracked) so the delta-apply below cannot lose them — EVERY caller is protected, not only the CLI wrapper, so the "nothing is rewritten or lost" promise holds of the WORKTREE and is no longer contingent on the caller. The returned snapshotHash is that commit ("" when the worktree had no tracked changes); callers surface it so the user can recover the preserved edits. The target is resolved to a concrete hash BEFORE snapshotting, so a relative targetRev like "HEAD~1" stays correct even though the snapshot advances HEAD.
It applies ONLY the tracked HEAD↔target delta (the FileChanges from Plan) to the worktree — it deliberately does NOT use go-git's HardReset. Unlike `git reset --hard`, go-git's HardReset enumerates and DELETES every untracked and gitignored worktree file; a revert sold as safe rollback must never destroy the user's own scratch files. Because only the diffed paths are touched, any untracked or gitignored file the user dropped into the dir survives byte-for-byte and stays untracked.
If a failure strikes AFTER the snapshot advanced HEAD (mid delta-apply or at the final commit), the returned error carries a recovery hint naming the pre-revert HEAD and the snapshot commit (restoreFailureHint) so a half-applied worktree has a clear path back. A pre-flight refusal mutates no WORKTREE file and returns its own actionable error without that hint — but the safety snapshot above may already have advanced HEAD (it runs before the pre-flight, deliberately: refusing must never cost the user their uncommitted edits), so callers surface snapshotHash even on error.
func (*Repo) SnapshotDirtyTracked ¶
SnapshotDirtyTracked commits any uncommitted changes to already-TRACKED files (modifications and deletions) as a safety checkpoint, so a subsequent destructive operation (notably revert's restore) cannot lose them. Untracked files — the user's own scratch files — are never touched. Returns the snapshot commit hash, or ("", nil) when the worktree has no tracked changes. This is what keeps the append-only promise true of the WORKTREE, not just of history.
CAUTION (issue #126): the snapshot commits the dest files verbatim, and dest files hold secrets resolved to cleartext — so a freshly hand-typed secret can land in this local-only history. That is acceptable only because the repo is never pushed; there is deliberately NO fail-closed backstop here (unlike capture.Capture's), so callers surface a cleartext caution to the user instead.
func (*Repo) SnapshotPreApply ¶ added in v0.11.0
SnapshotPreApply is SnapshotDirtyTracked plus the pre-apply content of plannedRels: slash-relative paths the imminent apply will write or delete. A planned path that exists on disk but is UNTRACKED (it was created while git backup was off or declined) has bytes nowhere in history — if the apply then overwrites or deletes it, those bytes are unrecoverable unless the baseline stages them now. Untracked files NOT in plannedRels remain the user's own and are never touched (#128). A GITIGNORED planned path is silently not staged — go-git's status omits ignored files — so its pre-apply bytes are not baselined; the user opted that path out of versioning. Returns ("", nil) when there is nothing to commit. The issue #126 cleartext caution on SnapshotDirtyTracked applies here identically.
func (*Repo) Stage ¶
Stage adds each of relPaths (slash-relative to the repo root) to the index. A path that is gone from the worktree is staged as a deletion if it is tracked.
func (*Repo) StageTrackedDeletions ¶
StageTrackedDeletions stages the removal of any already-TRACKED file that is now missing from the worktree — so an apply that deleted a managed file (e.g. a dropped MCP server) records that deletion in the checkpoint. It deliberately does NOT touch untracked files (a `?` status) the user may have dropped into the dir: agentsync only versions what it wrote. Returns the staged paths.
func (*Repo) UntrackedPaths ¶ added in v0.11.0
UntrackedPaths returns the sorted slash-relative paths of files in the worktree that git is not tracking (a `?` status) — the user's own scratch files that a revert leaves untouched. Note go-git's status does not enumerate gitignored files, so those (also preserved by revert) do not appear here.
type State ¶
type State int
State classifies how a destination dir is tracked, so the apply tail knows whether to init, commit, or stay out of the user's way.
func Detect ¶
Detect reports how dir is tracked. It uses DetectDotGit so a destination nested inside an existing repo (a dotfiles user who keeps ~/.claude under git) is reported StateForeign rather than StateUntracked — agentsync must not init a nested repo or commit into someone else's history.
State is meaningful only when err == nil. On any error path Detect returns the fail-safe StateForeign ("leave it alone"), never the init-eligible StateUntracked, so a future caller that reads State before checking err cannot classify an unreadable repo as eligible for init/commit.