Skip to content

Commit ff0449b

Browse files
committed
feat: delete a node from the DAG (agentenv delete + ctl + MCP)
Adds node deletion (previously you could only create nodes): - dag.Repo.Delete splices a node out, re-parenting its children to its parent so descendants survive; drops tags pointing at it. - repo.Delete: refuses to delete the current HEAD (would orphan the live work tree — checkout elsewhere first) or the only node; persists metadata then removes the snapshot dir. Safe even when children hardlink-share the deleted node's files (shared inodes stay alive — same reason gc is safe). - Surfaces as 'agentenv delete <node>', the daemon 'delete' op (ctl + bare command auto-routes), and the agentenv__delete MCP tool so Claude Code can prune dead-end explorations itself. Verified: deleting a middle node re-parents children; checkout of a survivor still reads its content (hardlinks intact); the deleted snapshot dir is gone; deleting HEAD is refused.
1 parent 6d50466 commit ff0449b

9 files changed

Lines changed: 125 additions & 4 deletions

File tree

internal/api/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ func dispatch(r *repo.Repo, c *repo.Capturer, req request) response {
176176
}
177177
return response{OK: true, Head: r.Head()}
178178

179+
case "delete":
180+
if err := r.Delete(req.Node); err != nil {
181+
return response{Error: err.Error()}
182+
}
183+
return response{OK: true, Head: r.Head()}
184+
179185
case "commit":
180186
n, err := r.Commit(req.Message, "")
181187
if err != nil {

internal/cli/cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ var commandList = []*command{
4747
{"branches", "", "list branch tips (distinct explored end-states)", false, cmdBranches},
4848
{"show", "<node>", "files this node changed vs its parent", false, cmdShow},
4949
{"diff", "<a> <b>", "files that differ between two nodes", false, cmdDiff},
50+
{"delete", "<node>", "remove a node from the DAG (children re-parent to its parent)", true, cmdDelete},
5051
{"gc", "", "delete orphan snapshots (reclaims sparsified ones)", true, cmdGC},
5152
}
5253

internal/cli/commands.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,19 @@ func cmdTournament(r *repo.Repo, args []string) error {
310310
return nil
311311
}
312312

313+
// cmdDelete removes a node from the commit-DAG (and its snapshot). Children are
314+
// re-parented to the node's parent, so descendants survive.
315+
func cmdDelete(r *repo.Repo, args []string) error {
316+
if len(args) < 1 {
317+
return fmt.Errorf("usage: agentenv delete <node>")
318+
}
319+
if err := r.Delete(args[0]); err != nil {
320+
return err
321+
}
322+
fmt.Printf("deleted %s\n", args[0])
323+
return nil
324+
}
325+
313326
func cmdGC(r *repo.Repo, _ []string) error {
314327
removed, err := r.GC()
315328
if err != nil {

internal/cli/ctl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func cmdCtl(args []string) error {
2727
req := map[string]any{"op": op}
2828
switch op {
2929
case "log", "head", "branches", "gc", "ps":
30-
case "checkout", "show":
30+
case "checkout", "show", "delete":
3131
if len(a) < 1 {
3232
return fmt.Errorf("usage: agentenv ctl %s <node>", op)
3333
}

internal/cli/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
// are excluded — they must own the lock themselves.
2222
func ctlRoutable(name string) bool {
2323
switch name {
24-
case "checkout", "commit", "exec", "tag", "gc", "tournament":
24+
case "checkout", "commit", "exec", "tag", "gc", "tournament", "delete":
2525
return true
2626
}
2727
return false

internal/dag/dag.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,54 @@ func (r *Repo) Get(id string) (*Node, bool) {
139139
return n, ok
140140
}
141141

142+
// Delete removes node id from the graph, splicing it out: its children are
143+
// re-parented to id's parent (so the tree stays connected — deleting a middle
144+
// node keeps its descendants), and any tags pointing at id are dropped. The
145+
// caller is responsible for the policy checks (not HEAD, not the only node) and
146+
// for removing the on-disk snapshot. Returns id's former parent ("" if it was a
147+
// root) and whether the node existed.
148+
func (r *Repo) Delete(id string) (parent string, ok bool) {
149+
n, ok := r.Nodes[id]
150+
if !ok {
151+
return "", false
152+
}
153+
parent = n.Parent
154+
for _, cid := range n.Children {
155+
c := r.Nodes[cid]
156+
if c == nil {
157+
continue
158+
}
159+
c.Parent = parent
160+
if parent != "" {
161+
if p := r.Nodes[parent]; p != nil {
162+
p.Children = append(p.Children, cid)
163+
}
164+
}
165+
}
166+
if parent != "" {
167+
if p := r.Nodes[parent]; p != nil {
168+
p.Children = removeID(p.Children, id)
169+
}
170+
}
171+
delete(r.Nodes, id)
172+
for name, tid := range r.Tags {
173+
if tid == id {
174+
delete(r.Tags, name)
175+
}
176+
}
177+
return parent, true
178+
}
179+
180+
func removeID(ids []string, id string) []string {
181+
out := ids[:0]
182+
for _, x := range ids {
183+
if x != id {
184+
out = append(out, x)
185+
}
186+
}
187+
return out
188+
}
189+
142190
// Roots returns all nodes without a parent (normally just one).
143191
func (r *Repo) Roots() []*Node {
144192
var out []*Node

internal/mcp/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ func register(s *mcp.Server, sock string) {
122122
}, func(_ context.Context, _ *mcp.CallToolRequest, in nodeArgs) (*mcp.CallToolResult, any, error) {
123123
return relay(sock, "checkout", map[string]any{"node": in.Node}, formatHead)
124124
})
125+
126+
mcp.AddTool(s, &mcp.Tool{
127+
Name: "agentenv__delete",
128+
Description: "Delete a node from the history (accepts node id, ID prefix, or tag). Its children re-parent to its parent, so descendants survive. Refuses to delete the current HEAD (check out another node first) or the only node. Use to prune dead-end explorations.",
129+
}, func(_ context.Context, _ *mcp.CallToolRequest, in nodeArgs) (*mcp.CallToolResult, any, error) {
130+
return relay(sock, "delete", map[string]any{"node": in.Node}, formatDeleted)
131+
})
132+
}
133+
134+
func formatDeleted(r *protocol.Response) string {
135+
return "deleted; HEAD is " + r.Head
125136
}
126137

127138
// relay issues a single daemon round-trip and packages the response. Errors

internal/repo/repo.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,48 @@ func (r *Repo) Checkout(ref string) error {
686686
return r.dag.Save()
687687
}
688688

689+
// Delete removes a node from the DAG and deletes its on-disk snapshot. Its
690+
// children are re-parented to its parent so the tree stays connected (deleting
691+
// a middle node keeps its descendants). Refuses to delete the current HEAD (it
692+
// would orphan the live working tree — checkout elsewhere first) or the last
693+
// remaining node. Deleting a node's snapshot is safe even if children
694+
// hardlink-share its files: the shared inodes stay alive as long as a child
695+
// still references them (same reason GC is safe).
696+
func (r *Repo) Delete(ref string) error {
697+
r.opMu.Lock()
698+
defer r.opMu.Unlock()
699+
700+
id := r.resolveRefLocked(ref)
701+
if id == "" {
702+
return fmt.Errorf("unknown ref: %s", ref)
703+
}
704+
if id == r.dag.Head {
705+
return fmt.Errorf("refusing to delete the current HEAD node %s — `agentenv checkout <other>` first", short(id))
706+
}
707+
if len(r.dag.Nodes) <= 1 {
708+
return fmt.Errorf("refusing to delete the only node")
709+
}
710+
if _, ok := r.dag.Delete(id); !ok {
711+
return fmt.Errorf("no such node: %s", id)
712+
}
713+
// Persist the metadata first (consistent DAG), then remove the snapshot dir.
714+
// If the rm fails afterward it's a harmless orphan that `gc` reclaims.
715+
if err := r.dag.Save(); err != nil {
716+
return err
717+
}
718+
if err := r.be.Snapshotter.DeleteNode(id); err != nil {
719+
fmt.Fprintf(os.Stderr, "agentenv: WARN deleted node %s from the DAG but could not remove its snapshot (gc will): %v\n", short(id), err)
720+
}
721+
return nil
722+
}
723+
724+
func short(id string) string {
725+
if len(id) > 12 {
726+
return id[:12]
727+
}
728+
return id
729+
}
730+
689731
// Head returns the current HEAD node ID.
690732
func (r *Repo) Head() string { return r.dag.Head }
691733

verify/docker/mcp-smoke.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ ok "initialize → protocol=$PROTOCOL serverInfo=$SRV_NAME"
8585
# --- id=2: tools/list -------------------------------------------------------
8686
TOOLS_LIST=$(byid 2)
8787
TOOL_COUNT=$(echo "$TOOLS_LIST" | jq '.result.tools | length')
88-
[ "$TOOL_COUNT" = "6" ] || die "tools/list: expected 6 tools, got $TOOL_COUNT"
89-
for want in agentenv__head agentenv__log agentenv__branches agentenv__show agentenv__diff agentenv__checkout; do
88+
[ "$TOOL_COUNT" = "7" ] || die "tools/list: expected 7 tools, got $TOOL_COUNT"
89+
for want in agentenv__head agentenv__log agentenv__branches agentenv__show agentenv__diff agentenv__checkout agentenv__delete; do
9090
echo "$TOOLS_LIST" | jq -e ".result.tools[] | select(.name == \"$want\")" >/dev/null \
9191
|| die "tools/list: missing $want"
9292
done

0 commit comments

Comments
 (0)