From 6add2bae652a5983fe0ffe9171e92710051714c5 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:31:03 -0400 Subject: [PATCH 1/3] feat: adopt sessions across worktrees Entire-Checkpoint: 7fb3d4d78c7f --- cmd/entire/cli/session_adopt.go | 260 +++++++++++++++++++++++++++ cmd/entire/cli/session_adopt_test.go | 218 ++++++++++++++++++++++ cmd/entire/cli/sessions.go | 3 + 3 files changed, 481 insertions(+) create mode 100644 cmd/entire/cli/session_adopt.go create mode 100644 cmd/entire/cli/session_adopt_test.go diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go new file mode 100644 index 0000000000..5f7fd2de2d --- /dev/null +++ b/cmd/entire/cli/session_adopt.go @@ -0,0 +1,260 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/spf13/cobra" +) + +type adoptOptions struct { + FromWorktree string + Force bool +} + +const adoptRecentWindow = 12 * time.Hour + +func newAdoptCmd() *cobra.Command { + var opts adoptOptions + + cmd := &cobra.Command{ + Use: "adopt [session-id]", + Short: "Adopt an active session from another worktree", + Long: `Adopt an active session from another worktree into the current repository. + +This is useful when an agent starts in one repository or worktree, then moves +and makes changes in another. Adoption copies the live session state into the +current repo and seeds it with the current repo's uncommitted file changes so +the next commit can be linked normally.`, + Example: ` entire session adopt 019ed5fe-ec49-7a72-89fd-f38e323f5448 --from ../cli + entire session adopt --from /path/to/source/worktree`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sessionID := "" + if len(args) > 0 { + sessionID = args[0] + } + return runAdopt(cmd.Context(), cmd.OutOrStdout(), sessionID, opts) + }, + } + + cmd.Flags().StringVar(&opts.FromWorktree, "from", "", "source worktree that already tracks the session") + cmd.Flags().BoolVar(&opts.Force, "force", false, "replace an existing local state file for the same session") + cmd.Flags().BoolVar(&opts.Force, "yes", false, "replace an existing local state file for the same session") + + return cmd +} + +func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOptions) error { + if strings.TrimSpace(opts.FromWorktree) == "" { + return errors.New("source worktree is required; pass --from ") + } + + sourceStore, sourceWorktree, err := stateStoreForWorktree(ctx, opts.FromWorktree) + if err != nil { + return err + } + + sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID) + if err != nil { + return err + } + + adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState) + if err != nil { + return err + } + + targetStore, err := session.NewStateStore(ctx) + if err != nil { + return fmt.Errorf("open current session store: %w", err) + } + existing, err := targetStore.Load(ctx, adopted.SessionID) + if err != nil { + return fmt.Errorf("load current session state: %w", err) + } + if existing != nil && !opts.Force { + return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID) + } + if err := targetStore.Save(ctx, adopted); err != nil { + return fmt.Errorf("save adopted session state: %w", err) + } + + fmt.Fprintf(w, "Adopted session %s from %s\n", shortSessionID(adopted.SessionID), sourceWorktree) + if len(filesTouched) == 0 { + fmt.Fprintln(w, "No current file changes were detected, so the next commit may not link until hooks record changes.") + return nil + } + fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) + return nil +} + +func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, error) { + absWorktree, err := filepath.Abs(worktreePath) + if err != nil { + return nil, "", fmt.Errorf("resolve source worktree: %w", err) + } + + cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir") + output, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(output)) + if msg != "" { + return nil, "", fmt.Errorf("resolve source git directory: %s: %w", msg, err) + } + return nil, "", fmt.Errorf("resolve source git directory: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) < 2 { + return nil, "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output))) + } + sourceRoot := strings.TrimSpace(lines[0]) + commonDir := strings.TrimSpace(lines[1]) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(absWorktree, commonDir) + } + commonDir = filepath.Clean(commonDir) + + return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, nil +} + +func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) { + if sessionID != "" { + sourceState, err := store.Load(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("load source session state: %w", err) + } + if sourceState == nil { + return nil, fmt.Errorf("session %s was not found in %s", sessionID, sourceWorktree) + } + if sourceState.Phase == session.PhaseEnded || sourceState.FullyCondensed { + return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID) + } + return sourceState, nil + } + + states, err := store.List(ctx) + if err != nil { + return nil, fmt.Errorf("list source sessions: %w", err) + } + candidates := make([]*session.State, 0, len(states)) + for _, state := range states { + if isRecentAdoptCandidate(state) { + candidates = append(candidates, state) + } + } + sort.Slice(candidates, func(i, j int) bool { + return sessionLastSeen(candidates[i]).After(sessionLastSeen(candidates[j])) + }) + + switch len(candidates) { + case 0: + return nil, fmt.Errorf("no recent active sessions found in %s", sourceWorktree) + case 1: + return candidates[0], nil + default: + ids := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + ids = append(ids, candidate.SessionID) + } + return nil, fmt.Errorf("multiple recent active sessions found in %s; pass one of: %s", + sourceWorktree, strings.Join(ids, ", ")) + } +} + +func isRecentAdoptCandidate(state *session.State) bool { + if state == nil || state.Phase == session.PhaseEnded || state.FullyCondensed { + return false + } + lastSeen := sessionLastSeen(state) + if lastSeen.IsZero() { + return false + } + return time.Since(lastSeen) <= adoptRecentWindow +} + +func sessionLastSeen(state *session.State) time.Time { + if state.LastInteractionTime != nil { + return *state.LastInteractionTime + } + return state.StartedAt +} + +func buildAdoptedSessionState(ctx context.Context, source *session.State) (*session.State, []string, error) { + repo, err := openRepository(ctx) + if err != nil { + return nil, nil, fmt.Errorf("open current repository: %w", err) + } + defer repo.Close() + + head, err := repo.Head() + if err != nil { + return nil, nil, fmt.Errorf("resolve current HEAD: %w", err) + } + + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return nil, nil, fmt.Errorf("resolve current worktree root: %w", err) + } + worktreeID, err := paths.GetWorktreeID(worktreeRoot) + if err != nil { + return nil, nil, fmt.Errorf("resolve current worktree ID: %w", err) + } + + branch, branchErr := GetCurrentBranch(ctx) + if branchErr != nil { + branch = "" + } + filesTouched, err := currentFilesTouched(ctx) + if err != nil { + return nil, nil, err + } + + now := time.Now() + adopted := *source + adopted.CLIVersion = versioninfo.Version + adopted.BaseCommit = head.Hash().String() + adopted.AttributionBaseCommit = head.Hash().String() + adopted.WorktreePath = worktreeRoot + adopted.WorktreeID = worktreeID + adopted.Branch = branch + adopted.LastInteractionTime = &now + adopted.FilesTouched = filesTouched + adopted.TurnCheckpointIDs = nil + adopted.LastCheckpointID = id.EmptyCheckpointID + adopted.LastCheckpointCommitHash = "" + adopted.FullyCondensed = false + adopted.DivergenceNoticeShown = false + adopted.UntrackedFilesAtStart = nil + adopted.PromptAttributions = nil + adopted.PendingPromptAttribution = nil + adopted.PromptWindowBase = 0 + adopted.PromptWindowResetPending = false + adopted.AttachedManually = true + + return &adopted, filesTouched, nil +} + +func currentFilesTouched(ctx context.Context) ([]string, error) { + changes, err := DetectFileChanges(ctx, nil) + if err != nil { + return nil, fmt.Errorf("detect current file changes: %w", err) + } + files := mergeUnique(nil, changes.Modified) + files = mergeUnique(files, changes.New) + files = mergeUnique(files, changes.Deleted) + sort.Strings(files) + return files, nil +} diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go new file mode 100644 index 0000000000..339b842cbc --- /dev/null +++ b/cmd/entire/cli/session_adopt_test.go @@ -0,0 +1,218 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-session-001" + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"role":"user","content":"update target file"},"uuid":"u1"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + lastInteraction := time.Now().Add(-1 * time.Minute) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "update target file", + FilesTouched: []string{"source-only.txt"}, + TurnCheckpointIDs: []string{"abc123def456"}, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + targetStore, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + adopted, err := targetStore.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if adopted == nil { + t.Fatal("expected adopted session state in target repo") + } + if adopted.WorktreePath != targetRepo { + t.Fatalf("WorktreePath = %q, want %q", adopted.WorktreePath, targetRepo) + } + if adopted.BaseCommit != testutil.GetHeadHash(t, targetRepo) { + t.Fatalf("BaseCommit = %q, want target HEAD", adopted.BaseCommit) + } + if adopted.TranscriptPath != transcriptPath { + t.Fatalf("TranscriptPath = %q, want %q", adopted.TranscriptPath, transcriptPath) + } + if !adopted.AttachedManually { + t.Fatal("expected adopted session to be marked manual") + } + if len(adopted.FilesTouched) != 1 || adopted.FilesTouched[0] != "feature.txt" { + t.Fatalf("FilesTouched = %v, want [feature.txt]", adopted.FilesTouched) + } + if len(adopted.TurnCheckpointIDs) != 0 { + t.Fatalf("TurnCheckpointIDs = %v, want empty target-local checkpoint bookkeeping", adopted.TurnCheckpointIDs) + } + if !bytes.Contains(out.Bytes(), []byte("Adopted session")) { + t.Fatalf("output = %q, want adoption confirmation", out.String()) + } +} + +func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sessionID := "test-adopt-trailer-001" + targetRelPath := "src/feature.go" + targetAbsPath := filepath.Join(targetRepo, targetRelPath) + + transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl") + if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil { + t.Fatal(err) + } + transcript := `{"type":"human","message":{"content":"write feature.go"}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"` + targetAbsPath + `","content":"package src\n"}}]}} +` + if err := os.WriteFile(transcriptPath, []byte(transcript), 0o600); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-3 * time.Minute) + if err := os.Chtimes(transcriptPath, stale, stale); err != nil { + t.Fatal(err) + } + + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + TranscriptPath: transcriptPath, + LastPrompt: "write feature.go", + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n") + testutil.GitAdd(t, targetRepo, targetRelPath) + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceRepo, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed: %v", err) + } + + commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG") + if err := os.WriteFile(commitMsgFile, []byte("add feature\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil { + t.Fatalf("PrepareCommitMsg failed: %v", err) + } + + content, err := os.ReadFile(commitMsgFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Entire-Checkpoint:") { + t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content)) + } +} + +func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) { + sourceRepo := setupAdoptRepo(t) + targetRepo := setupAdoptRepo(t) + + sourceSubdir := filepath.Join(sourceRepo, "nested", "dir") + if err := os.MkdirAll(sourceSubdir, 0o750); err != nil { + t.Fatal(err) + } + + sessionID := "test-adopt-from-subdir" + lastInteraction := time.Now().Add(-1 * time.Minute) + sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName)) + if err := sourceStore.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now().Add(-5 * time.Minute), + LastInteractionTime: &lastInteraction, + Phase: session.PhaseActive, + BaseCommit: testutil.GetHeadHash(t, sourceRepo), + WorktreePath: sourceRepo, + }); err != nil { + t.Fatal(err) + } + + testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n") + t.Chdir(targetRepo) + + var out bytes.Buffer + err := runAdopt(context.Background(), &out, sessionID, adoptOptions{ + FromWorktree: sourceSubdir, + Force: true, + }) + if err != nil { + t.Fatalf("runAdopt failed from source subdir: %v", err) + } +} + +func setupAdoptRepo(t *testing.T) string { + t.Helper() + + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "init.txt", "init\n") + testutil.GitAdd(t, repoDir, "init.txt") + testutil.GitCommit(t, repoDir, "init") + enableEntire(t, repoDir) + realRepoDir, err := filepath.EvalSymlinks(repoDir) + if err != nil { + t.Fatal(err) + } + return realRepoDir +} diff --git a/cmd/entire/cli/sessions.go b/cmd/entire/cli/sessions.go index d60f05f3ff..73529d04d4 100644 --- a/cmd/entire/cli/sessions.go +++ b/cmd/entire/cli/sessions.go @@ -167,6 +167,7 @@ Commands: stop Stop one or more active sessions current Show the active session for the current worktree attach Attach an existing agent session + adopt Adopt an active session from another worktree resume Switch to a branch and resume its session Examples: @@ -176,6 +177,7 @@ Examples: entire session stop Interactive stop entire session current Active session for cwd entire session attach Attach an external session + entire session adopt --from ../repo Adopt a moved session entire session resume Resume from a branch`, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if _, err := paths.WorktreeRoot(cmd.Context()); err != nil { @@ -190,6 +192,7 @@ Examples: cmd.AddCommand(newStopCmd()) cmd.AddCommand(newSessionCurrentCmd()) cmd.AddCommand(newAttachCmd()) + cmd.AddCommand(newAdoptCmd()) cmd.AddCommand(newResumeCmd()) return cmd From aae06c4e100bf091dd305b222df469ee9cab4228 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:44:45 -0400 Subject: [PATCH 2/3] fix: address session adopt review findings Entire-Checkpoint: b7f863b91041 --- cmd/entire/cli/session_adopt.go | 9 ++++++++- cmd/entire/cli/session_adopt_test.go | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 5f7fd2de2d..68f0fdd83c 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -97,6 +97,7 @@ func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOpti return nil } fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", ")) + fmt.Fprintln(w, "Review tracked files before committing; adoption attributes current changes in this repo to the adopted session.") return nil } @@ -232,9 +233,15 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.Branch = branch adopted.LastInteractionTime = &now adopted.FilesTouched = filesTouched + + // Reset target-local checkpoint bookkeeping. Source checkpoint IDs can point + // at metadata in another repository or checkpoint branch; carrying them into + // this repo would let amend and turn-finalization paths operate on unrelated + // checkpoints. adopted.TurnCheckpointIDs = nil adopted.LastCheckpointID = id.EmptyCheckpointID adopted.LastCheckpointCommitHash = "" + adopted.FullyCondensed = false adopted.DivergenceNoticeShown = false adopted.UntrackedFilesAtStart = nil @@ -242,7 +249,7 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess adopted.PendingPromptAttribution = nil adopted.PromptWindowBase = 0 adopted.PromptWindowResetPending = false - adopted.AttachedManually = true + adopted.AttachedManually = false return &adopted, filesTouched, nil } diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 339b842cbc..b56e7e38b1 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -43,6 +43,7 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { LastPrompt: "update target file", FilesTouched: []string{"source-only.txt"}, TurnCheckpointIDs: []string{"abc123def456"}, + AttachedManually: true, }); err != nil { t.Fatal(err) } @@ -79,8 +80,8 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { if adopted.TranscriptPath != transcriptPath { t.Fatalf("TranscriptPath = %q, want %q", adopted.TranscriptPath, transcriptPath) } - if !adopted.AttachedManually { - t.Fatal("expected adopted session to be marked manual") + if adopted.AttachedManually { + t.Fatal("adopted active sessions should not be marked manually attached") } if len(adopted.FilesTouched) != 1 || adopted.FilesTouched[0] != "feature.txt" { t.Fatalf("FilesTouched = %v, want [feature.txt]", adopted.FilesTouched) @@ -91,6 +92,9 @@ func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) { if !bytes.Contains(out.Bytes(), []byte("Adopted session")) { t.Fatalf("output = %q, want adoption confirmation", out.String()) } + if !bytes.Contains(out.Bytes(), []byte("Review tracked files before committing")) { + t.Fatalf("output = %q, want tracked-file attribution warning", out.String()) + } } func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) { From 9ffec38cf2fad2c962d48267aa9a1738358f0070 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 18 Jun 2026 18:55:14 -0400 Subject: [PATCH 3/3] fix: clarify adopted transcript path Entire-Checkpoint: 0d316d428771 --- cmd/entire/cli/session_adopt.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/entire/cli/session_adopt.go b/cmd/entire/cli/session_adopt.go index 68f0fdd83c..fc89dea7a4 100644 --- a/cmd/entire/cli/session_adopt.go +++ b/cmd/entire/cli/session_adopt.go @@ -225,7 +225,12 @@ func buildAdoptedSessionState(ctx context.Context, source *session.State) (*sess now := time.Now() adopted := *source + + // Keep the source live transcript path. In cross-repo adoption the transcript + // belongs to the continuing agent session, not the target repository; clearing + // or recomputing it from the target repo would drop live transcript capture. adopted.CLIVersion = versioninfo.Version + adopted.TranscriptPath = source.TranscriptPath adopted.BaseCommit = head.Hash().String() adopted.AttributionBaseCommit = head.Hash().String() adopted.WorktreePath = worktreeRoot